diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9752ef0..c9fc041 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -32,6 +32,28 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "alsa" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3" +dependencies = [ + "alsa-sys", + "bitflags 2.10.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -551,6 +573,51 @@ dependencies = [ "libc", ] +[[package]] +name = "coreaudio-rs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" +dependencies = [ + "bitflags 1.3.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb" +dependencies = [ + "alsa", + "coreaudio-rs", + "dasp_sample", + "jack", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -666,6 +733,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "data-encoding" version = "2.9.0" @@ -1020,6 +1093,7 @@ dependencies = [ name = "frangipane" version = "0.1.0" dependencies = [ + "cpal", "serde", "serde_json", "tauri", @@ -1033,6 +1107,7 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-upload", "tauri-plugin-websocket", + "tracing", ] [[package]] @@ -1853,6 +1928,33 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jack" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73213dab741ae0a4623824289625611d11bb8254ad1fdefc84e480f7632aa528" +dependencies = [ + "bitflags 2.10.0", + "jack-sys", + "lazy_static", + "libc", + "log", +] + +[[package]] +name = "jack-sys" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6013b7619b95a22b576dfb43296faa4ecbe40abbdb97dfd22ead520775fc86ab" +dependencies = [ + "bitflags 1.3.2", + "lazy_static", + "libc", + "libloading", + "log", + "pkg-config", +] + [[package]] name = "javascriptcore-rs" version = "1.1.2" @@ -2066,6 +2168,15 @@ dependencies = [ "time", ] +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -2235,6 +2346,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2297,6 +2419,31 @@ dependencies = [ "objc2-quartz-core", ] +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.10.0", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-cloud-kit" version = "0.3.2" @@ -2308,6 +2455,29 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.10.0", + "objc2", +] + [[package]] name = "objc2-core-data" version = "0.3.2" @@ -2326,7 +2496,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.10.0", + "block2", "dispatch2", + "libc", "objc2", ] @@ -4724,9 +4896,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -4746,9 +4918,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f847f6e..bc6ab8a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,4 +30,6 @@ tauri-plugin-dialog = "2" tauri-plugin-fs = "2" tauri-plugin-os = "2" tauri-plugin-notification = "2" +cpal = { version = "0.17.1", features = ["jack"] } +tracing = "0.1.44" diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist new file mode 100644 index 0000000..704072b --- /dev/null +++ b/src-tauri/Info.plist @@ -0,0 +1,8 @@ + + + + + NSMicrophoneUsageDescription + Request microphone access for WebRTC + + diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index cb0eae5..75cb90a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -57,4 +57,5 @@ "os:default", "notification:default" ] -} \ No newline at end of file +} + diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 087a6dd..0c81649 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,9 @@ - + + + @@ -35,4 +37,4 @@ android:resource="@xml/file_paths" /> - + \ No newline at end of file diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs new file mode 100644 index 0000000..16e7b8e --- /dev/null +++ b/src-tauri/src/audio.rs @@ -0,0 +1,175 @@ +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::SampleFormat; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Emitter}; + +pub struct AudioState { + stream: Arc>>, +} + +impl AudioState { + pub fn new() -> Self { + Self { + stream: Arc::new(Mutex::new(None)), + } + } +} + +#[derive(Clone, serde::Serialize)] +struct MicConfig { + sample_rate: u32, + channels: usize, +} + +#[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"), + ); + + #[cfg(any(not(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd" + ))))] + let host = cpal::default_host(); + + let device = 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 buffer_threshold = (sample_rate as usize / 10) * 2; + + app.emit( + "microphone-config", + MicConfig { + sample_rate, + channels, + }, + ) + .map_err(|e| e.to_string())?; + + 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 => { + 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 + } else { + (s * 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(); + } + }, + err_fn, + None, + ) + } + 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()); + } + + 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(); + } + }, + err_fn, + None, + ) + } + _ => return Err("Unsupported sample format".to_string()), + } + .map_err(|e| e.to_string())?; + + stream.play().map_err(|e| e.to_string())?; + *state.stream.lock().unwrap() = Some(stream); + + Ok(()) +} + +#[tauri::command] +pub fn stop_microphone(state: tauri::State) -> Result<(), String> { + let mut stream_guard = state.stream.lock().unwrap(); + *stream_guard = None; + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4edcbc6..5ace2b3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,12 +1,15 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +mod audio; +use audio::{start_microphone, stop_microphone}; + #[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) +fn log(message: &str) { + println!("[JS] {}", message); } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .manage(audio::AudioState::new()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_fs::init()) @@ -16,7 +19,11 @@ pub fn run() { .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) + .invoke_handler(tauri::generate_handler![ + start_microphone, + stop_microphone, + log + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index bda76cc..32a2c97 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,6 +1,8 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod audio; + fn main() { frangipane_lib::run() } diff --git a/src-tauri/tauri.android.conf.json b/src-tauri/tauri.android.conf.json new file mode 100644 index 0000000..2ee114f --- /dev/null +++ b/src-tauri/tauri.android.conf.json @@ -0,0 +1,7 @@ +{ + "bundle": { + "android": { + "minSdkVersion": 26 + } + } +} diff --git a/src/App.vue b/src/App.vue index ab47a1c..794a3c1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -26,6 +26,8 @@ + + @@ -40,6 +42,7 @@ import { apiFetch } from './api/client' import { VersionResponse } from './types' import { getCurrentWindow } from '@tauri-apps/api/window' import { platform } from '@tauri-apps/plugin-os'; +import VoiceControl from './components/VoiceControl.vue' const currentPlatform = ref('') diff --git a/src/api/messages.ts b/src/api/messages.ts index dd50b7a..dceb680 100644 --- a/src/api/messages.ts +++ b/src/api/messages.ts @@ -2,24 +2,24 @@ import { apiFetch } from './client' import type { Message } from '../types' export function fetchMessages(roomUuid: string, before?: string, limit: number = 30) { - let url = `/messages/${roomUuid}?limit=${limit}`; - if (before) { - url += `&before=${before}`; - } - return apiFetch(url); + let url = `/messages/${roomUuid}?limit=${limit}`; + if (before) { + url += `&before=${before}`; + } + return apiFetch(url); } export function sendMessage(roomUuid: string, content: string) { - return apiFetch(`/messages/${roomUuid}`, { - method: 'POST', - body: JSON.stringify({ - message_type: 'text', - content, - }), - }) + return apiFetch(`/messages/${roomUuid}`, { + method: 'POST', + body: JSON.stringify({ + message_type: 'text', + content, + }), + }) } export async function getWsToken(): Promise { - const res = await apiFetch<{ token: string }>(`/ws/messages/issue-token`); - return res.token; + const res = await apiFetch<{ token: string }>(`/ws/issue-token`); + return res.token; } diff --git a/src/components/ChatWindow.vue b/src/components/ChatWindow.vue index e451fa3..a927772 100644 --- a/src/components/ChatWindow.vue +++ b/src/components/ChatWindow.vue @@ -44,6 +44,11 @@ + + + + {{ connectionError }} @@ -68,6 +73,7 @@ import { getAuthData } from "../store.ts"; import { fetchRoomInfo } from "../api/rooms.ts"; import { useFluent } from 'fluent-vue'; import { sendNotification } from '@tauri-apps/plugin-notification'; +import { voiceActions, voiceState } from "../voice"; const { $t } = useFluent(); @@ -109,6 +115,10 @@ const handleRoomChanged = () => { emit('room-action'); }; +const isCurrentRoomVoice = computed(() => { + return voiceState.currentRoomUuid === props.uuid && voiceState.status === 'connected'; +}); + onMounted(async () => { await connectGlobalWebSocket(); @@ -172,7 +182,7 @@ async function connectGlobalWebSocket() { try { // Get a one-time token for the connection - const res = await apiFetch<{ token: string }>('/ws/messages/issue-token'); + const res = await apiFetch<{ token: string }>('/ws/issue-token'); const wsToken = res.token; const url = `${API_WS}/messages?token=${wsToken}`; @@ -309,6 +319,14 @@ async function onSend(content: string) { if (props.uuid === 'none') return; await sendMessage(props.uuid, content); } + +async function toggleVoice() { + if (isCurrentRoomVoice.value) { + await voiceActions.leaveRoom(); + } else { + await voiceActions.joinRoom(props.uuid); + } +} diff --git a/src/components/VoiceControl.vue b/src/components/VoiceControl.vue new file mode 100644 index 0000000..1b53853 --- /dev/null +++ b/src/components/VoiceControl.vue @@ -0,0 +1,154 @@ + + + + + + Voice Connected + + Speaking: {{ speakersList.length }} + + Room Quiet + + + + + + + + + + + + + + + + + + diff --git a/src/main.ts b/src/main.ts index a4ae5f4..d41c425 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,15 +3,13 @@ import router from './router.ts' import App from './App.vue' import { validateToken, initTheme } from './store.ts' import { fluent, setLanguage } from './i18n' -// import { -// createChannel, -// Importance, -// Visibility, -// } from '@tauri-apps/plugin-notification'; - - import './base.css' import { getLocalePreference } from './store.ts' +import { invoke } from '@tauri-apps/api/core' + +export function logJS(msg: any) { + invoke('log', { message: typeof msg === 'string' ? msg : JSON.stringify(msg) }).catch(() => { }); +} async function init() { await validateToken() @@ -43,14 +41,22 @@ async function init() { // sound: 'notification_sound', // }); + // window.addEventListener("error", (event) => { + // logJS(`Uncaught JS Error: ${event.message} at ${event.filename}:${event.lineno}:${event.colno}`); + // }); + // + // window.addEventListener("unhandledrejection", (event) => { + // logJS(`Unhandled Promise Rejection: ${JSON.stringify(event.reason)}`); + // }); + app.mount('#app') } init() -export const API = 'http://127.0.0.1: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 = '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 = 'wss://alatreon.org/frangipane/ws' +export const API_WS = 'wss://alatreon.org/frangipane/ws' diff --git a/src/store.ts b/src/store.ts index c645b7e..6bafafd 100644 --- a/src/store.ts +++ b/src/store.ts @@ -14,153 +14,153 @@ let store: Store | null = null export const initAuth = getAuthData async function getStore() { - if (!store) store = await load('store.json') - return store + if (!store) store = await load('store.json') + return store } export async function getAuthData() { - const s = await getStore() - const token = await s.get('token') - const user = await s.get('user') - return { token: token || null, user: user || null, isAuthenticated: !!token } + const s = await getStore() + const token = await s.get('token') + const user = await s.get('user') + return { token: token || null, user: user || null, isAuthenticated: !!token } } export async function saveAuthData(token: string, user: User) { - const s = await getStore() - await s.set('token', token) - await s.set('user', user) - await s.save() + const s = await getStore() + await s.set('token', token) + await s.set('user', user) + await s.save() } export async function clearAuthData() { - const s = await getStore() - s.clear() - await s.save() + const s = await getStore() + s.clear() + await s.save() } export async function updateLocalUser(newData: UpdateUserResponse, uuid?: string) { - const updatedUser = { - username: newData.username, - email: newData.email, - uuid, - avatar_url: `${getAvatar(uuid || '')}?t=${Date.now()}` - } + const updatedUser = { + username: newData.username, + email: newData.email, + uuid, + avatar_url: `${getAvatar(uuid || '')}?t=${Date.now()}` + } - const s = await getStore() - await s.set('user', updatedUser) - await s.save() + const s = await getStore() + await s.set('user', updatedUser) + await s.save() } export async function refreshLocalUser() { - const s = await getStore() - const user = await s.get('user') + const s = await getStore() + const user = await s.get('user') - if (user) { - user.avatar_url = `${getAvatar(user.uuid)}?t=${Date.now()}` + if (user) { + user.avatar_url = `${getAvatar(user.uuid)}?t=${Date.now()}` - await s.set('user', user) - await s.save() - } + await s.set('user', user) + await s.save() + } } export async function setLastRoom(uuid: string) { - if (!uuid || uuid === 'none') return - const s = await getStore() - await s.set('last_room_uuid', uuid) - await s.save() + if (!uuid || uuid === 'none') return + const s = await getStore() + await s.set('last_room_uuid', uuid) + await s.save() } export async function getLastRoom(): Promise { - const s = await getStore() - return (await s.get('last_room_uuid')) ?? null + const s = await getStore() + return (await s.get('last_room_uuid')) ?? null } export async function saveLocalePreference(locale: string) { - const s = await getStore() - await s.set('language', locale) - await s.save() + const s = await getStore() + await s.set('language', locale) + await s.save() } export async function getLocalePreference(): Promise { - const s = await getStore() - return (await s.get('language')) ?? null + const s = await getStore() + return (await s.get('language')) ?? null } export async function login(email: string, username: string, password: string) { - const res: LoginResponse = await apiFetch('/login', { - method: 'POST', - body: JSON.stringify({ email, username, password }), - }) + const res: LoginResponse = await apiFetch('/login', { + method: 'POST', + body: JSON.stringify({ email, username, password }), + }) - let user: User = { - uuid: res.uuid, - username: res.username, - email: res.email, - avatar_url: getAvatar(res.uuid) - }; - await saveAuthData(res.token, user) - return { token: res.token, uuid: res.uuid, isAuthenticated: true } + let user: User = { + uuid: res.uuid, + username: res.username, + email: res.email, + avatar_url: getAvatar(res.uuid) + }; + await saveAuthData(res.token, user) + return { token: res.token, uuid: res.uuid, isAuthenticated: true } } export async function logout() { - await clearAuthData() - return { token: null, uuid: null, isAuthenticated: false } + await clearAuthData() + return { token: null, uuid: null, isAuthenticated: false } } export async function validateToken(): Promise { - const auth = await getAuthData() - if (!auth.token) return false + const auth = await getAuthData() + if (!auth.token) return false - try { - await apiFetch('/validate-token') - return true - } catch (e) { - return false - } + try { + await apiFetch('/validate-token') + return true + } catch (e) { + return false + } } export async function register(email: string, username: string, password: string) { - const response: LoginResponse = await apiFetch('/register', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, username, password }) - }); + const response: LoginResponse = await apiFetch('/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, username, password }) + }); - await login(email, username, password) - return response; + await login(email, username, password) + return response; } const requests = ref([]) const invites = ref([]) export function useNotifications() { - const totalCount = computed(() => requests.value.length + invites.value.length) + const totalCount = computed(() => requests.value.length + invites.value.length) - async function refreshNotifications() { - const auth = await getAuthData() - if (!auth.token) { - return + async function refreshNotifications() { + const auth = await getAuthData() + if (!auth.token) { + return + } + + try { + const [fReqs, rInvs] = await Promise.all([ + fetchFriendRequests(), + fetchRoomInvites() + ]) + requests.value = fReqs + invites.value = rInvs + } catch (err) { + console.error("Failed to fetch notifications", err) + } } - try { - const [fReqs, rInvs] = await Promise.all([ - fetchFriendRequests(), - fetchRoomInvites() - ]) - requests.value = fReqs - invites.value = rInvs - } catch (err) { - console.error("Failed to fetch notifications", err) + return { + requests, + invites, + totalCount, + refreshNotifications } - } - - return { - requests, - invites, - totalCount, - refreshNotifications - } } // A reactive object to store the last updated timestamp for each user @@ -168,71 +168,71 @@ const avatarTimestamps = reactive>({}) export function getAvatar(uuid: string): string { - return `${API}/account/get-avatar/${uuid}`; + return `${API}/account/get-avatar/${uuid}`; } // Generates the avatar URL with a timestamp export function getAvatarUrl(uuid: string | undefined | null) { - if (!uuid) return '' + if (!uuid) return '' - if (!avatarTimestamps[uuid]) { - avatarTimestamps[uuid] = Date.now() - } + if (!avatarTimestamps[uuid]) { + avatarTimestamps[uuid] = Date.now() + } - return `${getAvatar(uuid)}?t=${avatarTimestamps[uuid]}` + return `${getAvatar(uuid)}?t=${avatarTimestamps[uuid]}` } export function refreshAvatar(uuid: string) { - avatarTimestamps[uuid] = Date.now() + avatarTimestamps[uuid] = Date.now() } // ==== Color themes ==== export async function saveThemePreference(themeId: string) { - const s = await getStore(); - await s.set('theme', themeId); - await s.save(); - applyTheme(themeId); + const s = await getStore(); + await s.set('theme', themeId); + await s.save(); + applyTheme(themeId); } export async function initTheme() { - const s = await getStore(); - const themeId = (await s.get('theme')) || 'default'; - applyTheme(themeId); + const s = await getStore(); + const themeId = (await s.get('theme')) || 'default'; + applyTheme(themeId); } export async function getThemePreference(): Promise { - const s = await getStore() - return (await s.get('theme')) ?? 'default' + const s = await getStore() + return (await s.get('theme')) ?? 'default' } export async function applyStoredTheme() { - const theme = await getThemePreference(); - document.documentElement.setAttribute('data-theme', theme); + const theme = await getThemePreference(); + document.documentElement.setAttribute('data-theme', theme); } // ==== Layout ==== export async function saveCompactLayoutPreference(enabled: boolean) { - const s = await getStore(); - await s.set('compact_layout', enabled); - await s.save(); + const s = await getStore(); + await s.set('compact_layout', enabled); + await s.save(); } export async function getCompactLayoutPreference(): Promise { - const s = await getStore(); - return (await s.get('compact_layout')) ?? false; + const s = await getStore(); + return (await s.get('compact_layout')) ?? false; } // ==== Message draft ==== export async function saveMessageDraft(text: string) { - const s = await getStore() - await s.set('message_draft', text) - await s.save() + const s = await getStore() + await s.set('message_draft', text) + await s.save() } export async function getMessageDraft(): Promise { - const s = await getStore() - return (await s.get('message_draft')) ?? '' + const s = await getStore() + return (await s.get('message_draft')) ?? '' } diff --git a/src/voice.ts b/src/voice.ts new file mode 100644 index 0000000..6891e24 --- /dev/null +++ b/src/voice.ts @@ -0,0 +1,196 @@ +import { reactive } from 'vue'; +import { API_WS, logJS } from './main'; +import { apiFetch } from './api/client'; +import WebSocket from '@tauri-apps/plugin-websocket'; +import { invoke } from '@tauri-apps/api/core'; +import { listen, UnlistenFn } from '@tauri-apps/api/event'; + +interface VoiceState { + status: 'disconnected' | 'connecting' | 'connected'; + currentRoomUuid: string | null; + isMuted: boolean; + speakingUsers: Set; + captureSampleRate: number; + captureChannels: number; +} + +export const voiceState = reactive({ + status: 'disconnected', + currentRoomUuid: null, + isMuted: false, + speakingUsers: new Set(), + captureSampleRate: 48000, + captureChannels: 1, +}); + +let socket: WebSocket | null = null; +let audioContext: AudioContext | null = null; +let unlistenMic: UnlistenFn | null = null; +let unlistenConfig: UnlistenFn | null = null; +let unlistenError: UnlistenFn | null = null; + +let nextStartTime = 0; + +async function ensureMicrophonePermission() { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + // Stop it immediately so it releases the mic + stream.getTracks().forEach(track => track.stop()); + return true; + } catch (err) { + console.error("Microphone permission denied:", err); + return true; + } +} + +// Setup global error listener +export async function initVoiceListeners() { + if (unlistenError) return; + unlistenError = await listen('microphone-error', (event) => { + console.error("RUST AUDIO ERROR:", event.payload); + voiceActions.leaveRoom(); + }); +} + +export const voiceActions = { + async joinRoom(roomUuid: string) { + 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(); + } + }); + + await this.startAudioCapture(); + voiceState.status = 'connected'; + } catch (e) { + logJS(e); + console.error("Voice join failed", e); + voiceActions.leaveRoom(); + } + }, + + async leaveRoom() { + nextStartTime = 0; + voiceState.status = 'disconnected'; + voiceState.currentRoomUuid = null; + voiceState.speakingUsers.clear(); + + await this.stopAudioCapture(); + + if (socket) { + // WebSocket plugin disconnect is async + await socket.disconnect(); + socket = null; + } + }, + + toggleMute() { + voiceState.isMuted = !voiceState.isMuted; + }, + + async startAudioCapture() { + 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 + 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; + } + }, + + async stopAudioCapture() { + try { + await invoke('stop_microphone'); + } catch (e) { console.warn(e); } + + if (unlistenMic) { unlistenMic(); unlistenMic = null; } + if (unlistenConfig) { unlistenConfig(); unlistenConfig = null; } + + if (audioContext) { + await audioContext.close(); + audioContext = null; + } + } + +}; + +function handleIncomingAudio(data: number[]) { + if (!audioContext) return; + + const arrayBuffer = new Uint8Array(data).buffer; + const audioDataOffset = 16; // Skip UUID + + if (arrayBuffer.byteLength <= audioDataOffset) return; + const audioByteLength = arrayBuffer.byteLength - audioDataOffset; + + const pcmData = new Int16Array(arrayBuffer, audioDataOffset, audioByteLength / 2); + const float32Data = new Float32Array(pcmData.length); + for (let i = 0; i < pcmData.length; i++) { + float32Data[i] = pcmData[i] / 32768.0; + } + + const buffer = audioContext.createBuffer( + 1, + float32Data.length, + voiceState.captureSampleRate + ); + buffer.getChannelData(0).set(float32Data); + + const source = audioContext.createBufferSource(); + source.buffer = buffer; + source.connect(audioContext.destination); + + const now = audioContext.currentTime; + + if (nextStartTime === 0) nextStartTime = now + 0.05; + + // If drifted too far behind (lag > 0.5s), jump ahead + if (nextStartTime < now) { + nextStartTime = now; + } + + if (nextStartTime > now + 5.0) { + nextStartTime = now; + } + + source.start(nextStartTime); + nextStartTime += buffer.duration; + +}
{{ connectionError }}