diff --git a/package.json b/package.json index 2ea92ed..04afa7d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "frangipane-client", "private": true, "version": "1.0.2", - "backendVersion": "1.0.5", + "backendVersion": "1.0.6", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 16e7b8e..3373776 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1,5 +1,5 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; -use cpal::SampleFormat; +use cpal::{Host, HostId, SampleFormat}; use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Emitter}; @@ -21,52 +21,85 @@ struct MicConfig { channels: usize, } +/// Helper to resolve host string to cpal::Host +fn get_host_by_name(name: &str) -> Result { + let host_id = cpal::available_hosts() + .into_iter() + .find(|id| id.name() == name) + .ok_or_else(|| format!("Host '{}' not found", name))?; + + cpal::host_from_id(host_id).map_err(|e| e.to_string()) +} + #[tauri::command] -pub fn start_microphone(app: AppHandle, state: tauri::State) -> Result<(), String> { - #[cfg(all(any( - target_os = "linux", - target_os = "dragonfly", - target_os = "freebsd", - target_os = "netbsd" - )))] - let host = cpal::host_from_id( - cpal::available_hosts() - .into_iter() - .find(|id| *id == cpal::HostId::Jack) - .expect("jack host unavailable"), - ) - .unwrap_or( - cpal::host_from_id(*cpal::available_hosts().first().expect("no host available")) - .expect("host not available"), - ); +pub fn get_audio_hosts() -> Vec { + cpal::available_hosts() + .into_iter() + .map(|id| id.name().to_string()) + .collect() +} - #[cfg(any(not(any( - target_os = "linux", - target_os = "dragonfly", - target_os = "freebsd", - target_os = "netbsd" - ))))] - let host = cpal::default_host(); +#[tauri::command] +pub fn get_input_devices(host_name: Option) -> Result, String> { + // If no host provided, use default + let host = if let Some(name) = host_name { + get_host_by_name(&name)? + } else { + cpal::default_host() + }; - let device = host - .default_input_device() - .ok_or("No input device available")?; + let devices = host.input_devices().map_err(|e| e.to_string())?; + + let device_names: Vec<(String, String)> = devices + .into_iter() + .filter_map(|device| { + Some(( + device.id().ok()?.to_string(), + device.description().ok()?.to_string(), + )) + }) + .collect(); + + Ok(device_names) +} + +#[tauri::command] +pub fn start_microphone( + app: AppHandle, + state: tauri::State, + host_name: Option, + device_id: Option, +) -> Result<(), String> { + let host = if let Some(ref h_name) = host_name { + get_host_by_name(h_name)? + } else { + cpal::default_host() + }; + + let device = if let Some(ref id) = device_id { + host.input_devices() + .map_err(|e| e.to_string())? + .find(|d| d.id().map(|i| i.to_string()) == Ok(id.to_string())) + .ok_or_else(|| format!("Device '{}' not found on host '{:?}'", id, host.id()))? + } else { + host.default_input_device() + .ok_or("No input device available")? + }; let config = device .default_input_config() .map_err(|e| format!("Failed to get config: {}", e))?; - println!("Microphone Config: {:?}", config); let sample_rate = config.sample_rate(); let device_channels = config.channels() as usize; - let channels = 1; // NOTE: temporary + let channels = 1; let buffer_threshold = (sample_rate as usize / 10) * 2; app.emit( "microphone-config", MicConfig { - sample_rate, + sample_rate: sample_rate, channels, }, ) @@ -74,39 +107,24 @@ pub fn start_microphone(app: AppHandle, state: tauri::State) -> Resu let sample_format = config.sample_format(); let stream_config: cpal::StreamConfig = config.into(); - let err_fn = |err| eprintln!("Stream error: {}", err); let app_handle = app.clone(); - let mut emit_if_full = move |buffer: &mut Vec| { - if buffer.len() >= buffer_threshold { - let payload = buffer.clone(); - let _ = app_handle.emit("microphone-data", payload); - buffer.clear(); - } - }; - - let app_handle = app.clone(); - let stream = match sample_format { - SampleFormat::F32 => { + cpal::SampleFormat::F32 => { let mut local_buffer = Vec::with_capacity(buffer_threshold * 2); - device.build_input_stream( &stream_config, move |data: &[f32], _| { for frame in data.chunks(device_channels) { - let sample = frame[0]; // Take first channel - let s = sample.clamp(-1.0, 1.0); - let v = if s >= 0.0 { - (s * 32767.0) as i16 + let sample = frame[0].clamp(-1.0, 1.0); + let v = if sample >= 0.0 { + (sample * 32767.0) as i16 } else { - (s * 32768.0) as i16 + (sample * 32768.0) as i16 }; local_buffer.extend_from_slice(&v.to_le_bytes()); } - - // Only emit if we have enough data if local_buffer.len() >= buffer_threshold { let _ = app_handle.emit("microphone-data", &local_buffer); local_buffer.clear(); @@ -116,38 +134,14 @@ pub fn start_microphone(app: AppHandle, state: tauri::State) -> Resu None, ) } - SampleFormat::I16 => { + cpal::SampleFormat::I16 => { let mut local_buffer = Vec::with_capacity(buffer_threshold * 2); - device.build_input_stream( &stream_config, move |data: &[i16], _| { for frame in data.chunks(device_channels) { - let sample = frame[0]; - local_buffer.extend_from_slice(&sample.to_le_bytes()); + local_buffer.extend_from_slice(&frame[0].to_le_bytes()); } - - if local_buffer.len() >= buffer_threshold { - let _ = app_handle.emit("microphone-data", &local_buffer); - local_buffer.clear(); - } - }, - err_fn, - None, - ) - } - SampleFormat::U16 => { - let mut local_buffer = Vec::with_capacity(buffer_threshold * 2); - - device.build_input_stream( - &stream_config, - move |data: &[u16], _| { - for frame in data.chunks(device_channels) { - let sample = frame[0]; - let v = (sample as i32 - 32768) as i16; - local_buffer.extend_from_slice(&v.to_le_bytes()); - } - if local_buffer.len() >= buffer_threshold { let _ = app_handle.emit("microphone-data", &local_buffer); local_buffer.clear(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5ace2b3..0c20e3d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,5 @@ mod audio; -use audio::{start_microphone, stop_microphone}; +use audio::{get_audio_hosts, get_input_devices, start_microphone, stop_microphone}; #[tauri::command] fn log(message: &str) { @@ -22,7 +22,9 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ start_microphone, stop_microphone, - log + log, + get_input_devices, + get_audio_hosts ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d1e85c4..aaa6ab5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "frangipane", - "version": "1.0.1", + "version": "1.0.2", "identifier": "com.strawberries.frangipane", "build": { "beforeDevCommand": "yarn dev", diff --git a/src/components/ChatWindow.vue b/src/components/ChatWindow.vue index a927772..9b35739 100644 --- a/src/components/ChatWindow.vue +++ b/src/components/ChatWindow.vue @@ -7,6 +7,8 @@ :ownerUuid="currentRoom?.owner_uuid || ''" @close="showDetailsModal = false" @room-changed="handleRoomChanged" /> + +
@@ -44,8 +46,7 @@ - @@ -68,6 +69,7 @@ import MessageList from "./MessageList.vue"; import MessageInput from "./MessageInput.vue"; import InvitePeopleModal from './InvitePeopleModal.vue'; import RoomDetailsModal from "./RoomDetailsModal.vue"; +import VoiceDeviceModal from "./VoiceDeviceModal.vue"; import WebSocket from '@tauri-apps/plugin-websocket'; import { getAuthData } from "../store.ts"; import { fetchRoomInfo } from "../api/rooms.ts"; @@ -96,9 +98,11 @@ const connectionError = ref(null); // Pagination State const isLoadingMore = ref(false); const hasMore = ref(true); +const isInitialLoad = ref(false); + const showInviteModal = ref(false); const showDetailsModal = ref(false); -const isInitialLoad = ref(false); +const showVoiceModal = ref(false); // WebSocket State let socket: WebSocket | null = null; @@ -324,7 +328,19 @@ async function toggleVoice() { if (isCurrentRoomVoice.value) { await voiceActions.leaveRoom(); } else { - await voiceActions.joinRoom(props.uuid); + showVoiceModal.value = true; + } +} + +async function handleVoiceSelect(selection: { host: string | null, device: string | null }) { + showVoiceModal.value = false; + + if (!selection) return; + + try { + await voiceActions.joinRoom(props.uuid, selection.host, selection.device); + } catch (e) { + console.error("Failed to join voice", e); } } diff --git a/src/components/VoiceDeviceModal.vue b/src/components/VoiceDeviceModal.vue new file mode 100644 index 0000000..93b8353 --- /dev/null +++ b/src/components/VoiceDeviceModal.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/src/locales/en.ftl b/src/locales/en.ftl index 7816b3c..b9c1ad9 100644 --- a/src/locales/en.ftl +++ b/src/locales/en.ftl @@ -50,6 +50,7 @@ chat-create-global = Global room chat-create-submit = Create chat-connecting = Connecting to room... chat-connecting-failed = Could not connect. Check internet connection. +chat-voice-select-input = Select input device. ## User profile profile-title = User profile diff --git a/src/locales/fr.ftl b/src/locales/fr.ftl index 537aa3c..7ad3c95 100644 --- a/src/locales/fr.ftl +++ b/src/locales/fr.ftl @@ -50,6 +50,7 @@ chat-create-global = Salon public chat-create-submit = Créer chat-connecting = Connexion au salon... chat-connecting-failed = Impossible d'établir la connexion. Vérifiez votre internet. +chat-voice-select-input = Sélectionnez un périphérique d'entrée. ## User profile profile-title = Profil d'utilisateur diff --git a/src/main.ts b/src/main.ts index 7ab0580..6593b10 100644 --- a/src/main.ts +++ b/src/main.ts @@ -54,9 +54,9 @@ async function init() { init() -export const API = 'http://127.0.0.1:8080' -// export const API = 'http://192.168.1.183:8080' +// export const API = 'http://127.0.0.1:8080' +export const API = 'http://192.168.1.183:8080' // export const API = 'https://alatreon.org/frangipane' -export const API_WS = 'ws://127.0.0.1:8080/ws' -// export const API_WS = 'ws://192.168.1.183:8080/ws' +// export const API_WS = 'ws://127.0.0.1:8080/ws' +export const API_WS = 'ws://192.168.1.183:8080/ws' // export const API_WS = 'wss://alatreon.org/frangipane/ws' diff --git a/src/pages/RegisterPage.vue b/src/pages/RegisterPage.vue index df6d422..788d86b 100644 --- a/src/pages/RegisterPage.vue +++ b/src/pages/RegisterPage.vue @@ -48,11 +48,9 @@ const errorMessage = ref(""); const router = useRouter(); -async function submit(event: Event) { +async function submit() { errorMessage.value = ""; - const form = event.target as HTMLFormElement; - // if (!form.checkValidity()) { // if (password.value.length < 8) { // errorMessage.value = $t('auth-error-password-length'); diff --git a/src/voice.ts b/src/voice.ts index 6891e24..5c5d0f3 100644 --- a/src/voice.ts +++ b/src/voice.ts @@ -1,5 +1,5 @@ import { reactive } from 'vue'; -import { API_WS, logJS } from './main'; +import { API_WS } from './main'; import { apiFetch } from './api/client'; import WebSocket from '@tauri-apps/plugin-websocket'; import { invoke } from '@tauri-apps/api/core'; @@ -53,34 +53,46 @@ export async function initVoiceListeners() { } export const voiceActions = { - async joinRoom(roomUuid: string) { + async getHosts(): Promise { + try { + return await invoke('get_audio_hosts'); + } catch (e) { + console.error(e); + return []; + } + }, + + async getDevices(hostName: string | null): Promise { + try { + return await invoke('get_input_devices', { hostName }); + } catch (e) { + console.error(e); + return []; + } + }, + + async joinRoom(roomUuid: string, hostName: string | null, deviceName: string | null) { if (voiceState.status === 'connected') return; nextStartTime = 0; voiceState.status = 'connecting'; voiceState.currentRoomUuid = roomUuid; - // Ensure listeners are active await initVoiceListeners(); try { const res = await apiFetch<{ token: string }>('/ws/issue-token'); const url = `${API_WS}/voice/${roomUuid}?token=${res.token}`; - socket = await WebSocket.connect(url); socket.addListener((msg) => { - if (msg.type === 'Binary') { - handleIncomingAudio(msg.data); - } else if (msg.type === 'Close') { - voiceActions.leaveRoom(); - } + if (msg.type === 'Binary') handleIncomingAudio(msg.data); + else if (msg.type === 'Close') voiceActions.leaveRoom(); }); - await this.startAudioCapture(); + await this.startAudioCapture(hostName, deviceName); voiceState.status = 'connected'; } catch (e) { - logJS(e); console.error("Voice join failed", e); voiceActions.leaveRoom(); } @@ -105,34 +117,25 @@ export const voiceActions = { voiceState.isMuted = !voiceState.isMuted; }, - async startAudioCapture() { + async startAudioCapture(hostName: string | null, deviceName: string | null) { const hasPermission = await ensureMicrophonePermission(); - if (!hasPermission) { - throw new Error("Microphone permission denied"); - } - // Give the OS a moment to release the mic resource from the WebView + if (!hasPermission) throw new Error("Microphone permission denied"); + await new Promise(r => setTimeout(r, 200)); audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); unlistenConfig = await listen<{ sample_rate: number, channels: number }>('microphone-config', (event) => { - console.log("Mic Config Received:", event.payload); voiceState.captureSampleRate = event.payload.sample_rate; voiceState.captureChannels = event.payload.channels; }); unlistenMic = await listen('microphone-data', (event) => { if (voiceState.isMuted || !socket) return; - socket.send(event.payload).catch(console.error); }); - try { - await invoke('start_microphone'); - } catch (e) { - console.error("Failed to start mic:", e); - throw e; - } + await invoke('start_microphone', { hostName, deviceName }); }, async stopAudioCapture() {