Merge pull request #6 from recurse-chat/voice-chat

Voice chat
This commit is contained in:
2026-08-27 14:46:31 -04:00
committed by GitHub
24 changed files with 1716 additions and 48 deletions
+281 -17
View File
@@ -32,6 +32,28 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "alsa"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d"
dependencies = [
"alsa-sys",
"bitflags 2.13.1",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "android_system_properties"
version = "0.1.6"
@@ -534,6 +556,50 @@ dependencies = [
"libc",
]
[[package]]
name = "coreaudio-rs"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58"
dependencies = [
"bitflags 2.13.1",
"libc",
"objc2-audio-toolbox",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
]
[[package]]
name = "cpal"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1"
dependencies = [
"alsa",
"block2",
"coreaudio-rs",
"dasp_sample",
"jni 0.22.4",
"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",
"web-sys",
"windows 0.62.2",
"windows-core 0.62.2",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -650,6 +716,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "dbus"
version = "0.9.12"
@@ -889,6 +961,9 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
name = "enclave"
version = "0.1.0"
dependencies = [
"cpal",
"resampler",
"ringbuf",
"serde",
"serde_json",
"tauri",
@@ -1861,6 +1936,36 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.20",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
@@ -2018,6 +2123,12 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "mach2"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
[[package]]
name = "markup5ever"
version = "0.38.0"
@@ -2107,6 +2218,12 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "ndk-context"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
@@ -2128,6 +2245,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[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.119",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2182,6 +2310,32 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc2-audio-toolbox"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
dependencies = [
"bitflags 2.13.1",
"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 = [
"bitflags 2.13.1",
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-cloud-kit"
version = "0.3.2"
@@ -2193,6 +2347,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.13.1",
"objc2",
]
[[package]]
name = "objc2-core-data"
version = "0.3.2"
@@ -2210,7 +2387,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.1",
"block2",
"dispatch2",
"libc",
"objc2",
]
@@ -2282,6 +2461,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.1",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2813,6 +2993,23 @@ dependencies = [
"web-sys",
]
[[package]]
name = "resampler"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28fdbea87ff02ebbfd904079d1e52138c0c7fbaa3aaddca4a1b9c7d3f85749f2"
[[package]]
name = "ringbuf"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a158e09ede21a14b172ca6cdd6208386c6ae2cb6acef58d774368ef8c450dfa7"
dependencies = [
"crossbeam-utils",
"portable-atomic",
"portable-atomic-util",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -3134,6 +3331,22 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
@@ -3340,7 +3553,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@@ -3356,7 +3569,7 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -3396,7 +3609,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -3427,7 +3640,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -3526,7 +3739,7 @@ dependencies = [
"tauri-plugin",
"thiserror 2.0.20",
"url",
"windows",
"windows 0.61.3",
"zbus",
]
@@ -3540,7 +3753,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -3552,7 +3765,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -3563,7 +3776,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -3577,7 +3790,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
"wry",
]
@@ -4325,7 +4538,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
@@ -4349,7 +4562,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.20",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
]
@@ -4405,11 +4618,23 @@ version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections",
"windows-collections 0.2.0",
"windows-core 0.61.2",
"windows-future",
"windows-future 0.2.1",
"windows-link 0.1.3",
"windows-numerics",
"windows-numerics 0.2.0",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections 0.3.2",
"windows-core 0.62.2",
"windows-future 0.3.2",
"windows-numerics 0.3.1",
]
[[package]]
@@ -4421,6 +4646,15 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.61.2"
@@ -4455,7 +4689,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
"windows-threading",
"windows-threading 0.1.0",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
"windows-threading 0.2.1",
]
[[package]]
@@ -4502,6 +4747,16 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -4605,6 +4860,15 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-version"
version = "0.1.7"
@@ -4768,7 +5032,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
@@ -4788,7 +5052,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
+3
View File
@@ -22,4 +22,7 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
cpal = "0.18.2"
resampler = "0.5.1"
ringbuf = "0.5.1"
+550
View File
@@ -0,0 +1,550 @@
use std::{
collections::BTreeMap,
net::UdpSocket,
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
thread,
time::{Duration, Instant},
};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use ringbuf::{
storage::Heap,
traits::{Consumer, Observer, Producer, Split},
CachingCons, CachingProd, HeapRb, SharedRb,
};
use tauri::State;
use crate::commands::config::ConfigState;
const PACKET_SAMPLES: usize = 960; // 20ms @ 48kHz
const HEADER_SIZE: usize = 4;
const MAX_PACKET_SIZE: usize = 4096;
const INITIAL_PACKET_CUSHION: usize = 3; // ~60ms cushion
// --- Voice Activity Detection (VAD) Settings ---
const VAD_THRESHOLD: f32 = 0.01;
const VAD_HANGOVER_FRAMES: usize = 10;
// ============================================================================
// STATE & TYPES
// ============================================================================
type AudioProducer = Arc<Mutex<CachingProd<Arc<SharedRb<Heap<f32>>>>>>;
type AudioConsumer = Arc<Mutex<CachingCons<Arc<SharedRb<Heap<f32>>>>>>;
#[derive(Clone, Default)]
pub struct VoiceState {
pub inner: Arc<VoiceStateInner>,
}
#[derive(Default)]
pub struct VoiceStateInner {
pub session: Mutex<Option<VoiceSession>>,
}
pub struct VoiceSession {
pub input_stream: cpal::Stream,
pub output_stream: Arc<Mutex<cpal::Stream>>,
pub socket: Arc<UdpSocket>,
pub pin: u64,
pub shutdown: Arc<AtomicBool>,
pub current_input_device: Option<String>,
pub current_output_device: Option<String>,
pub producer_in: AudioProducer,
pub consumer_out: AudioConsumer,
}
impl VoiceSession {
pub fn update_input_device(
&mut self,
device_name: Option<String>,
state_inner: Arc<VoiceStateInner>,
) -> Result<(), String> {
let host = cpal::default_host();
let device = match &device_name {
Some(name) => host
.input_devices()
.map_err(|e| e.to_string())?
.find(|d| {
d.description()
.ok()
.map(|x| x.name() == *name)
.unwrap_or(false)
})
.ok_or_else(|| format!("Input device '{name}' not found"))?,
None => host
.default_input_device()
.ok_or("No default input device")?,
};
let input_config = device
.default_input_config()
.map_err(|e| format!("Failed to get default input config: {e}"))?
.config();
let producer = Arc::clone(&self.producer_in);
let inner_clone = Arc::clone(&state_inner);
let new_stream = device
.build_input_stream(
input_config,
move |data: &[f32], _| {
if let Ok(mut prod) = producer.lock() {
let _ = prod.push_slice(data);
}
},
move |err| {
eprintln!("[vc] Input error: {err}. Attempting input stream recovery...");
if let Ok(mut lock) = inner_clone.session.lock() {
if let Some(session) = lock.as_mut() {
let target_device = session.current_input_device.clone();
if let Err(e) =
session.update_input_device(target_device, Arc::clone(&inner_clone))
{
eprintln!("[vc] Input recovery failed: {e}");
}
}
}
},
None,
)
.map_err(|e| e.to_string())?;
new_stream
.play()
.map_err(|e| format!("Failed to play input stream: {e}"))?;
self.input_stream = new_stream;
self.current_input_device = device_name;
Ok(())
}
pub fn update_output_device(
&mut self,
device_name: Option<String>,
state_inner: Arc<VoiceStateInner>,
) -> Result<(), String> {
let host = cpal::default_host();
let device = match &device_name {
Some(name) => host
.output_devices()
.map_err(|e| e.to_string())?
.find(|d| {
d.description()
.ok()
.map(|x| x.name() == *name)
.unwrap_or(false)
})
.ok_or_else(|| format!("Output device '{name}' not found"))?,
None => host
.default_output_device()
.ok_or("No default output device")?,
};
let output_config = device
.default_output_config()
.map_err(|e| format!("Failed to get default output config: {e}"))?
.config();
let consumer = Arc::clone(&self.consumer_out);
let new_stream = build_output_stream(&device, output_config, consumer, state_inner)?;
new_stream
.play()
.map_err(|e| format!("Failed to play output stream: {e}"))?;
if let Ok(mut active_stream) = self.output_stream.lock() {
*active_stream = new_stream;
}
self.current_output_device = device_name;
Ok(())
}
}
// ============================================================================
// DEVICES
// ============================================================================
#[tauri::command]
pub fn list_input_devices() -> Result<Vec<String>, String> {
let host = cpal::default_host();
Ok(host
.input_devices()
.map_err(|e| e.to_string())?
.filter_map(|d| d.description().ok().map(|x| x.name().to_string()))
.collect())
}
#[tauri::command]
pub fn list_output_devices() -> Result<Vec<String>, String> {
let host = cpal::default_host();
Ok(host
.output_devices()
.map_err(|e| e.to_string())?
.filter_map(|d| d.description().ok().map(|x| x.name().to_string()))
.collect())
}
// ============================================================================
// DISCONNECT
// ============================================================================
#[tauri::command]
pub fn disconnect_from_vc(voice_state: State<'_, VoiceState>) -> Result<(), String> {
let mut lock = voice_state
.inner
.session
.lock()
.map_err(|e| e.to_string())?;
if let Some(session) = lock.take() {
session.shutdown.store(true, Ordering::SeqCst);
let _ = session.input_stream.pause();
if let Ok(output) = session.output_stream.lock() {
let _ = output.pause();
}
}
Ok(())
}
// ============================================================================
// CONNECT
// ============================================================================
#[tauri::command]
pub fn connect_to_vc(
hostname: String,
pin: u64,
config_state: State<'_, ConfigState>,
voice_state: State<'_, VoiceState>,
) -> Result<(), String> {
disconnect_from_vc(voice_state.clone())?;
let config = config_state.0.lock().unwrap().clone();
let state_inner = Arc::clone(&voice_state.inner);
let socket = Arc::new(
UdpSocket::bind("0.0.0.0:0").map_err(|e| format!("Failed to bind UDP socket: {e}"))?,
);
socket
.connect(&hostname)
.map_err(|e| format!("Failed to connect UDP socket: {e}"))?;
socket
.set_read_timeout(Some(Duration::from_millis(5)))
.map_err(|e| e.to_string())?;
socket
.send(&pin.to_be_bytes())
.map_err(|e| format!("Failed to send pin: {e}"))?;
let host = cpal::default_host();
let input_device = match &config.input_device_name {
Some(name) => host
.input_devices()
.map_err(|e| e.to_string())?
.find(|d| {
d.description()
.ok()
.map(|x| x.name() == *name)
.unwrap_or(false)
})
.ok_or("Input device not found")?,
None => host
.default_input_device()
.ok_or("No default input device")?,
};
let output_device = match &config.output_device_name {
Some(name) => host
.output_devices()
.map_err(|e| e.to_string())?
.find(|d| {
d.description()
.ok()
.map(|x| x.name() == *name)
.unwrap_or(false)
})
.ok_or("Output device not found")?,
None => host
.default_output_device()
.ok_or("No default output device")?,
};
let shutdown = Arc::new(AtomicBool::new(false));
// Output Ring Buffer setup
let rb_out = HeapRb::<f32>::new(19200);
let (producer_out, consumer_out) = rb_out.split();
let mut producer_out = producer_out;
let shared_consumer_out = Arc::new(Mutex::new(consumer_out));
let output_config = output_device
.default_output_config()
.map_err(|e| format!("Failed to get default output config: {e}"))?
.config();
let output_stream = build_output_stream(
&output_device,
output_config,
Arc::clone(&shared_consumer_out),
Arc::clone(&state_inner),
)?;
output_stream
.play()
.map_err(|e| format!("Failed to start output: {e}"))?;
let output_stream = Arc::new(Mutex::new(output_stream));
// Input Ring Buffer setup
let rb_in = HeapRb::<f32>::new(19200);
let (producer_in, mut consumer_in) = rb_in.split();
let shared_producer_in = Arc::new(Mutex::new(producer_in));
let input_config = input_device
.default_input_config()
.map_err(|e| format!("Failed to get default input config: {e}"))?
.config();
let cb_producer = Arc::clone(&shared_producer_in);
let inner_input_err = Arc::clone(&state_inner);
let input_stream = input_device
.build_input_stream(
input_config,
move |data: &[f32], _| {
if let Ok(mut prod) = cb_producer.lock() {
let _ = prod.push_slice(data);
}
},
move |err| {
eprintln!("[vc] Input error: {err}. Attempting input stream recovery...");
if let Ok(mut lock) = inner_input_err.session.lock() {
if let Some(session) = lock.as_mut() {
let target_device = session.current_input_device.clone();
if let Err(e) =
session.update_input_device(target_device, Arc::clone(&inner_input_err))
{
eprintln!("[vc] Input recovery failed: {e}");
}
}
}
},
None,
)
.map_err(|e| e.to_string())?;
// UDP Sender Thread
{
let input_socket = socket.clone();
let shutdown = shutdown.clone();
thread::spawn(move || {
let mut sequence = 0u32;
let mut net_packet = vec![0u8; HEADER_SIZE + PACKET_SAMPLES * 2];
let mut frame_buf = vec![0.0f32; PACKET_SAMPLES];
let mut hangover_counter = 0;
while !shutdown.load(Ordering::Relaxed) {
if consumer_in.occupied_len() >= PACKET_SAMPLES {
let _ = consumer_in.pop_slice(&mut frame_buf);
let sum_squares: f32 = frame_buf.iter().map(|&s| s * s).sum();
let rms = (sum_squares / PACKET_SAMPLES as f32).sqrt();
let is_speaking = if rms >= VAD_THRESHOLD {
hangover_counter = VAD_HANGOVER_FRAMES;
true
} else if hangover_counter > 0 {
hangover_counter -= 1;
true
} else {
false
};
if is_speaking {
net_packet[0..4].copy_from_slice(&sequence.to_be_bytes());
for (i, sample) in frame_buf.iter().enumerate() {
let pcm = (sample.clamp(-1.0, 1.0) * 32767.0) as i16;
let offset = HEADER_SIZE + i * 2;
net_packet[offset..offset + 2].copy_from_slice(&pcm.to_be_bytes());
}
let _ = input_socket.send(&net_packet);
sequence = sequence.wrapping_add(1);
}
} else {
thread::sleep(Duration::from_millis(2));
}
}
});
}
// UDP Receiver Thread
{
let socket = socket.clone();
let shutdown = shutdown.clone();
thread::spawn(move || {
let mut packets: BTreeMap<u32, Vec<f32>> = BTreeMap::new();
let mut expected: Option<u32> = None;
let mut is_prebuffering = true;
let mut last_good_frame = vec![0.0f32; PACKET_SAMPLES];
let mut udp_buffer = [0u8; MAX_PACKET_SIZE];
let mut next_frame_time = Instant::now();
while !shutdown.load(Ordering::Relaxed) {
if let Ok(len) = socket.recv(&mut udp_buffer) {
if len > HEADER_SIZE {
let seq = u32::from_be_bytes(udp_buffer[0..4].try_into().unwrap());
let pcm = &udp_buffer[HEADER_SIZE..len];
let samples: Vec<f32> = pcm
.chunks_exact(2)
.map(|c| i16::from_be_bytes([c[0], c[1]]) as f32 / 32768.0)
.collect();
packets.insert(seq, samples);
}
}
if is_prebuffering {
if packets.len() >= INITIAL_PACKET_CUSHION {
expected = packets.keys().next().copied();
is_prebuffering = false;
next_frame_time = Instant::now();
} else {
thread::sleep(Duration::from_millis(1));
continue;
}
}
if Instant::now() >= next_frame_time {
if let Some(seq) = expected {
if let Some(samples) = packets.remove(&seq) {
if samples.len() == PACKET_SAMPLES {
last_good_frame.copy_from_slice(&samples);
} else {
last_good_frame.clear();
last_good_frame
.extend(samples.iter().take(PACKET_SAMPLES).copied());
if last_good_frame.len() < PACKET_SAMPLES {
last_good_frame.resize(PACKET_SAMPLES, 0.0);
}
}
let _ = producer_out.push_slice(&last_good_frame);
expected = Some(seq.wrapping_add(1));
} else if packets.keys().any(|&x| x > seq) {
for sample in last_good_frame.iter_mut() {
*sample *= 0.65;
}
let _ = producer_out.push_slice(&last_good_frame);
expected = Some(seq.wrapping_add(1));
} else if packets.is_empty() {
is_prebuffering = true;
}
}
next_frame_time += Duration::from_millis(20);
}
thread::sleep(Duration::from_millis(1));
}
});
}
input_stream
.play()
.map_err(|e| format!("Failed to start input: {e}"))?;
*state_inner.session.lock().unwrap() = Some(VoiceSession {
input_stream,
output_stream,
socket,
pin,
shutdown,
current_input_device: config.input_device_name,
current_output_device: config.output_device_name,
producer_in: shared_producer_in,
consumer_out: shared_consumer_out,
});
Ok(())
}
// ============================================================================
// AUDIO CALLBACK
// ============================================================================
fn build_output_stream(
device: &cpal::Device,
config: cpal::StreamConfig,
consumer: AudioConsumer,
state_inner: Arc<VoiceStateInner>,
) -> Result<cpal::Stream, String> {
let channels = config.channels as usize;
let mut last_sample = 0.0f32;
let inner_output_err = Arc::clone(&state_inner);
device
.build_output_stream(
config,
move |data: &mut [f32], _| {
let mut idx = 0;
let mut cons_guard = consumer.lock().ok();
while idx < data.len() {
let sample = cons_guard.as_mut().and_then(|c| c.try_pop());
if let Some(mono_sample) = sample {
last_sample = mono_sample;
for ch in 0..channels {
data[idx + ch] = mono_sample;
}
idx += channels;
} else {
while idx < data.len() {
last_sample *= 0.92;
for ch in 0..channels {
data[idx + ch] = last_sample;
}
idx += channels;
}
break;
}
}
},
move |err| {
eprintln!("[vc] Output error: {err}. Attempting output stream recovery...");
if let Ok(mut lock) = inner_output_err.session.lock() {
if let Some(session) = lock.as_mut() {
let target_device = session.current_output_device.clone();
if let Err(e) = session
.update_output_device(target_device, Arc::clone(&inner_output_err))
{
eprintln!("[vc] Output recovery failed: {e}");
}
}
}
},
None,
)
.map_err(|e| e.to_string())
}
impl Drop for VoiceSession {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::SeqCst);
let _ = self.input_stream.pause();
if let Ok(output) = self.output_stream.lock() {
let _ = output.pause();
}
eprintln!("[vc] VoiceSession dropped and audio streams paused.");
}
}
+77
View File
@@ -0,0 +1,77 @@
use cpal::traits::{DeviceTrait, HostTrait};
use serde::{Deserialize, Serialize};
use std::fs;
use std::sync::Mutex;
use tauri::{AppHandle, Manager, State};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Config {
pub input_device_name: Option<String>,
pub output_device_name: Option<String>,
pub input_volume: u8,
pub output_volume: u8,
}
impl Default for Config {
fn default() -> Self {
let host = cpal::default_host();
Self {
input_device_name: host
.default_input_device()
.and_then(|d| Some(d.description().ok()?.name().to_string())),
output_device_name: host
.default_output_device()
.and_then(|d| Some(d.description().ok()?.name().to_string())),
input_volume: 0,
output_volume: 0,
}
}
}
pub struct ConfigState(pub Mutex<Config>);
fn config_file_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Failed to resolve app data dir: {e}"))?;
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create app data dir: {e}"))?;
Ok(dir.join("config.json"))
}
#[tauri::command]
pub fn update_config(state: State<ConfigState>, config: Config) -> Result<(), String> {
*state.0.lock().unwrap() = config;
Ok(())
}
#[tauri::command]
pub fn save_config(app: AppHandle, state: State<ConfigState>) -> Result<(), String> {
let path = config_file_path(&app)?;
let config = state.0.lock().unwrap();
let json = serde_json::to_string_pretty(&*config)
.map_err(|e| format!("Failed to serialize config: {e}"))?;
fs::write(&path, json).map_err(|e| format!("Failed to write config: {e}"))?;
Ok(())
}
#[tauri::command]
pub fn get_config(app: AppHandle, state: State<ConfigState>) -> Result<Config, String> {
let path = config_file_path(&app)?;
let config = if path.exists() {
let json = fs::read_to_string(&path).map_err(|e| format!("Failed to read config: {e}"))?;
serde_json::from_str(&json).map_err(|e| format!("Failed to parse config: {e}"))?
} else {
Config::default()
};
*state.0.lock().unwrap() = config.clone();
Ok(config)
}
+2
View File
@@ -1,2 +1,4 @@
pub mod accounts;
pub mod audio;
pub mod config;
pub mod server_list;
+13
View File
@@ -1,3 +1,7 @@
use std::sync::Mutex;
use crate::commands::config::ConfigState;
pub mod commands;
pub mod types;
@@ -5,11 +9,20 @@ pub mod types;
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.manage(ConfigState(Mutex::new(commands::config::Config::default())))
.manage(commands::audio::VoiceState::default())
.invoke_handler(tauri::generate_handler![
commands::server_list::save_server_list,
commands::server_list::get_server_list,
commands::accounts::save_accounts,
commands::accounts::get_accounts,
commands::audio::list_input_devices,
commands::audio::list_output_devices,
commands::audio::connect_to_vc,
commands::audio::disconnect_from_vc,
commands::config::update_config,
commands::config::save_config,
commands::config::get_config,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+3
View File
@@ -10,6 +10,7 @@ import {
import Sidebar from "./components/view/Sidebar";
import { ThemeProvider } from "next-themes";
import Page from "./components/page/PageView";
import { SettingsDialog } from "./components/settings/SettingsDialog";
export default function App() {
const appRef = useRef<Enclave | null>(null);
@@ -54,6 +55,8 @@ export default function App() {
<Page appRef={appRef} />
</ResizablePanel>
</ResizablePanelGroup>
<SettingsDialog appRef={appRef} />
</>
)}
</main>
+35
View File
@@ -28,12 +28,14 @@ export default class Enclave<P = Page> {
public accounts?: AccountsFile;
public server?: EnclaveServer;
public serverList: ServerList;
public isSettingsOpen: boolean;
public forceRender: () => void;
public page?: P;
public constructor() {
this.serverList = {};
this.forceRender = () => {};
this.isSettingsOpen = false;
}
public async init() {
@@ -204,6 +206,39 @@ export default class Enclave<P = Page> {
server.messages[msg.channel_id][msg.message.id] = msg.message;
this.forceRender();
return;
case "JoinVoice":
server.voiceJoin && server.voiceJoin(msg.pin, msg.channel_id);
return;
case "UserJoinedVoice":
if (!server.voiceChatUsers[msg.channel_id])
server.voiceChatUsers[msg.channel_id] = [];
server.voiceChatUsers[msg.channel_id].push(msg.pubkey);
this.server?.getUsers([msg.pubkey]);
return;
case "UserLeftVoice":
if (!server.voiceChatUsers[msg.channel_id]) return;
server.voiceChatUsers[msg.channel_id] = server.voiceChatUsers[
msg.channel_id
].filter((v) => v !== msg.pubkey);
this.forceRender();
return;
case "Speaking":
clearTimeout(server.voiceChatSpeakers[msg.pubkey]);
server.voiceChatSpeakers[msg.pubkey] = setTimeout(() => {
delete server.voiceChatSpeakers[msg.pubkey];
this.forceRender();
}, 800);
this.forceRender();
return;
}
}
}
+26
View File
@@ -29,6 +29,25 @@ export type ClientMethod =
method: "MessageDeleted";
channel_id: string;
message_id: string;
}
| {
method: "JoinVoice";
channel_id: string;
pin: bigint;
}
| {
method: "UserJoinedVoice";
channel_id: string;
pubkey: string;
}
| {
method: "UserLeftVoice";
channel_id: string;
pubkey: string;
}
| {
method: "Speaking";
pubkey: string;
};
export type ServerMethod =
@@ -71,4 +90,11 @@ export type ServerMethod =
method: "DeleteMessage";
channel_id: string;
message_id: string;
}
| {
method: "JoinVoice";
channel_id: string;
}
| {
method: "LeaveVoice";
};
+7
View File
@@ -29,14 +29,21 @@ export default class EnclaveServer {
public isSecure: boolean;
public websocket?: EnclaveWebSocket;
public meta?: ServerMeta;
public messages: Record<string, Record<string, StoredMessage>>;
public users: Record<string, ClientMeta | null>;
public voiceJoin?: (pin: bigint, channelId: string) => void;
public voiceChatUsers: Record<string, string[]>;
public voiceChatSpeakers: Record<string, NodeJS.Timeout>;
public constructor(hostname: string, isSecure: boolean) {
this.hostname = hostname;
this.isSecure = isSecure;
this.messages = {};
this.users = {};
this.voiceChatUsers = {};
this.voiceChatSpeakers = {};
}
public disconnect() {
+4
View File
@@ -1,3 +1,4 @@
import { invoke } from "@tauri-apps/api/core";
import { ClientMethod, ServerMethod } from "./protocol";
/**
@@ -15,6 +16,9 @@ export default class EnclaveWebSocket {
this.onOpenQueue = new Array();
this.websocket = new WebSocket(hostname);
this.websocket.onclose = () => {
invoke("disconnect_from_vc");
};
this.websocket.onopen = () => {
this.onOpenQueue.forEach((fun) => fun());
};
@@ -1,6 +1,7 @@
import Enclave from "@/app/app";
import { ChannelPageProps } from "@/components/page/PageView";
import TextChannel from "./TextChannel";
import VoiceChannel from "./VoiceChannel";
export default function ChannelPage({
appRef,
@@ -12,5 +13,7 @@ export default function ChannelPage({
switch (appRef.current?.page?.channel.kind) {
case "text":
return <TextChannel appRef={appRef} />;
case "voice":
return <VoiceChannel appRef={appRef} />;
}
}
@@ -0,0 +1,167 @@
import Enclave from "@/app/app";
import { ChannelPageProps } from "../PageView";
import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Phone, PhoneOff } from "lucide-react";
export default function VoiceChannel({
appRef,
}: {
appRef: React.RefObject<Enclave<ChannelPageProps> | null>;
}) {
const channel = appRef.current?.page?.channel;
if (!channel) return null;
const [isConnected, setIsConnected] = useState(false);
const lastChannelId = useRef<string | null>(null);
// Leave current channel setup
const handleLeave = () => {
appRef.current?.server?.websocket?.send({ method: "LeaveVoice" });
invoke("disconnect_from_vc").catch(console.error);
setIsConnected(false);
};
// Explicit action to join the voice channel
const handleJoin = () => {
const hostname = appRef.current?.server?.hostname;
if (!hostname || channel.kind !== "voice" || !appRef.current?.server)
return;
appRef.current.server.voiceJoin = (pin, channelId) => {
invoke("connect_to_vc", { hostname, pin, channelId })
.then(() => setIsConnected(true))
.catch(console.error);
};
appRef.current.server.websocket?.send({
method: "JoinVoice",
channel_id: channel.id,
});
setIsConnected(true);
};
// Clean up when channel changes or component unmounts
useEffect(() => {
if (lastChannelId.current !== channel.id) {
if (isConnected) {
invoke("disconnect_from_vc").catch(console.error);
setIsConnected(false);
}
lastChannelId.current = channel.id;
}
return () => {
invoke("disconnect_from_vc").catch(console.error);
};
}, [channel.id]);
const speakers =
appRef.current?.server?.voiceChatSpeakers &&
Object.keys(appRef.current?.server?.voiceChatSpeakers);
const users = appRef.current?.server?.voiceChatUsers[channel.id];
const usersLength = users?.length || 0;
// Dynamic grid column layout based on user count
const getGridCols = () => {
if (usersLength <= 3) return "grid-cols-1";
if (usersLength <= 6) return "grid-cols-3";
if (usersLength <= 9) return "grid-cols-3";
return "grid-cols-5";
};
// Avatar sizing scales up for fewer participants
const getAvatarSize = () => {
if (usersLength <= 2) return "h-24 w-24 text-3xl";
if (usersLength <= 4) return "h-20 w-20 text-2xl";
return "h-14 w-14 text-lg";
};
return (
<div className="flex flex-col h-screen gap-2 pb-3">
{/* Header */}
<header className="px-3 pt-3 pb-3 text-sm text-muted-foreground border-b border-b-border flex justify-between items-center">
<h2>{channel.name}</h2>
<span className="text-xs">
{usersLength} {usersLength === 1 ? "Participant" : "Participants"}
</span>
</header>
{/* Main Grid */}
<div
className={cn(
"grid p-4 w-full gap-4 flex-1 auto-rows-fr bg-background/50 rounded-xl overflow-y-auto",
getGridCols(),
)}
>
{users?.map((pubkey) => {
const user = appRef.current?.server?.users[pubkey];
const isSpeaking = speakers?.includes(pubkey);
const displayName = user?.displayName || "Unknown User";
const fallbackLetter = displayName.charAt(0).toUpperCase();
return (
<div
key={pubkey}
className={cn(
"relative flex flex-col items-center justify-center p-4 rounded-xl bg-card/80 transition-all duration-150 overflow-hidden group h-full",
isSpeaking
? "ring-2 ring-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.25)] bg-card"
: "ring-1 ring-border/60 hover:ring-border",
)}
>
{/* Avatar Container */}
<div className="relative flex items-center justify-center">
<Avatar
className={cn("transition-all duration-200", getAvatarSize())}
>
<AvatarImage
src={user?.avatar}
alt={displayName}
className="object-cover"
/>
<AvatarFallback className="bg-muted font-bold text-muted-foreground">
{fallbackLetter}
</AvatarFallback>
</Avatar>
</div>
{/* Name Plate */}
<div className="absolute bottom-3 left-3 max-w-[85%] truncate bg-background/80 backdrop-blur-md text-foreground py-1 px-2.5 rounded-md border border-border/50 text-xs font-medium shadow-sm">
{displayName}
</div>
</div>
);
})}
</div>
{/* Bottom Control Bar */}
<footer className="flex items-center justify-center px-4 py-2 border-t border-border/40 bg-card/50 rounded-xl mx-2">
{isConnected ? (
<Button
variant="destructive"
onClick={handleLeave}
className="flex items-center gap-2 font-medium px-6"
>
<PhoneOff className="w-4 h-4" />
Disconnect
</Button>
) : (
<Button
variant="default"
onClick={handleJoin}
className="flex items-center gap-2 font-medium px-6 bg-emerald-600 hover:bg-emerald-700 text-white"
>
<Phone className="w-4 h-4" />
Join Voice
</Button>
)}
</footer>
</div>
);
}
@@ -0,0 +1,14 @@
import { Palette } from "lucide-react";
export function AppearanceSettings() {
return (
<div className="space-y-5 w-full">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Palette /> Appearance
</h2>
<p className="text-sm text-muted-foreground">
Theme and display settings go here.
</p>
</div>
);
}
@@ -0,0 +1,129 @@
import { useEffect, useState } from "react";
import { Volume2, Mic, Speaker } from "lucide-react";
import { invoke } from "@tauri-apps/api/core";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { getConfig, updateConfig, saveConfig, Config } from "@/lib/config";
export function AudioVideoSettings() {
const [config, setConfig] = useState<Config | null>(null);
const [inputDevices, setInputDevices] = useState<string[]>([]);
const [outputDevices, setOutputDevices] = useState<string[]>([]);
useEffect(() => {
getConfig().then(setConfig);
invoke<string[]>("list_input_devices").then(setInputDevices);
invoke<string[]>("list_output_devices").then(setOutputDevices);
}, []);
async function handleChange(patch: Partial<Config>) {
if (!config) return;
const updated = { ...config, ...patch };
setConfig(updated);
await updateConfig(updated);
await saveConfig();
}
if (!config) return null;
return (
<div className="space-y-8 w-full">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Volume2 /> Audio & Video
</h2>
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-medium">
<Mic className="h-4 w-4" />
Input
</div>
<div className="space-y-2">
<Label>Input device</Label>
<Select
value={config.inputDeviceName ?? undefined}
onValueChange={(value) => handleChange({ inputDeviceName: value })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a microphone" />
</SelectTrigger>
<SelectContent>
{inputDevices.map((device) => (
<SelectItem key={device} value={device}>
{device}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Input volume</Label>
<span className="text-xs text-muted-foreground">
{config.inputVolume}%
</span>
</div>
<Slider
value={[config.inputVolume]}
onValueChange={(value) =>
handleChange({ inputVolume: value as any })
}
max={100}
step={1}
/>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-medium">
<Speaker className="h-4 w-4" />
Output
</div>
<div className="space-y-2">
<Label>Output device</Label>
<Select
value={config.outputDeviceName ?? undefined}
onValueChange={(value) => handleChange({ outputDeviceName: value })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a speaker" />
</SelectTrigger>
<SelectContent>
{outputDevices.map((device) => (
<SelectItem key={device} value={device}>
{device}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Output volume</Label>
<span className="text-xs text-muted-foreground">
{config.outputVolume}%
</span>
</div>
<Slider
value={[config.outputVolume]}
onValueChange={(value) =>
handleChange({ outputVolume: value as any })
}
max={100}
step={1}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,14 @@
import { User } from "lucide-react";
export function ProfileSettings() {
return (
<div className="space-y-5 w-full">
<h2 className="text-lg font-semibold flex items-center gap-2">
<User /> Profile
</h2>
<p className="text-sm text-muted-foreground">
Display name and avatar settings go here.
</p>
</div>
);
}
@@ -0,0 +1,74 @@
import { useState } from "react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { User, Palette, Volume2 } from "lucide-react";
import { ProfileSettings } from "./ProfileSettings";
import { AppearanceSettings } from "./AppearanceSettings";
import { AudioVideoSettings } from "./AudioVideoSettings";
import Enclave from "@/app/app";
type SettingsCategory = "profile" | "appearance" | "audio-video";
const CATEGORIES: {
id: SettingsCategory;
label: string;
icon: React.ElementType;
}[] = [
{ id: "profile", label: "Profile", icon: User },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "audio-video", label: "Audio & Video", icon: Volume2 },
];
export function SettingsDialog({
appRef,
}: {
appRef: React.RefObject<Enclave | null>;
}) {
const [category, setCategory] = useState<SettingsCategory>("profile");
return (
<Dialog
open={appRef.current?.isSettingsOpen}
onOpenChange={(open) => {
if (!appRef.current) return;
appRef.current.isSettingsOpen = open;
appRef.current.forceRender();
}}
>
<DialogContent
className="flex h-[85vh] w-[90vw] max-w-5xl gap-0 overflow-hidden p-0 sm:max-w-5xl"
showCloseButton
>
<div className="flex w-56 shrink-0 flex-col border-r bg-muted/30 p-3">
<span className="mb-2 px-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Settings
</span>
<nav className="flex flex-col gap-0.5">
{CATEGORIES.map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setCategory(id)}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
category === id
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</nav>
</div>
<div className="flex-1 overflow-y-auto p-6">
{category === "profile" && <ProfileSettings />}
{category === "appearance" && <AppearanceSettings />}
{category === "audio-video" && <AudioVideoSettings />}
</div>
</DialogContent>
</Dialog>
);
}
+25 -26
View File
@@ -1,24 +1,24 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import * as React from "react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
@@ -29,12 +29,12 @@ function DialogOverlay({
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
"fixed inset-0 isolate z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 backdrop-blur-sm",
className,
)}
{...props}
/>
)
);
}
function DialogContent({
@@ -43,7 +43,7 @@ function DialogContent({
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
@@ -52,7 +52,7 @@ function DialogContent({
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
className,
)}
{...props}
>
@@ -68,14 +68,13 @@ function DialogContent({
/>
}
>
<XIcon
/>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -85,7 +84,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
);
}
function DialogFooter({
@@ -94,14 +93,14 @@ function DialogFooter({
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
className,
)}
{...props}
>
@@ -112,7 +111,7 @@ function DialogFooter({
</DialogPrimitive.Close>
)}
</div>
)
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
@@ -121,11 +120,11 @@ function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
className,
)}
{...props}
/>
)
);
}
function DialogDescription({
@@ -137,11 +136,11 @@ function DialogDescription({
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
className,
)}
{...props}
/>
)
);
}
export {
@@ -155,4 +154,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
}
};
+199
View File
@@ -0,0 +1,199 @@
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+52
View File
@@ -0,0 +1,52 @@
import { Slider as SliderPrimitive } from "@base-ui/react/slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: SliderPrimitive.Root.Props) {
const _values = Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max]
return (
<SliderPrimitive.Root
className={cn("data-horizontal:w-full data-vertical:h-full", className)}
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
thumbAlignment="edge"
{...props}
>
<SliderPrimitive.Control className="relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col">
<SliderPrimitive.Track
data-slot="slider-track"
className="relative grow overflow-hidden rounded-full bg-muted select-none data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1"
>
<SliderPrimitive.Indicator
data-slot="slider-range"
className="bg-primary select-none data-horizontal:h-full data-vertical:w-full"
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Control>
</SliderPrimitive.Root>
)
}
export { Slider }
+10 -1
View File
@@ -41,7 +41,16 @@ export default function AccountCard({
</div>
</div>
<div className="flex items-center text-muted-foreground">
<Button variant="ghost" className="size-10">
<Button
variant="ghost"
className="size-10"
onClick={() => {
if (!appRef.current) return;
appRef.current.isSettingsOpen = !appRef.current?.isSettingsOpen;
appRef.current.forceRender();
}}
>
<Settings2Icon className="size-5.5" />
</Button>
</div>
+5 -3
View File
@@ -1,16 +1,18 @@
import Enclave from "@/app/app";
import { Channel, ChannelKind } from "@/lib/types";
import { ChevronDown, ChevronUp, HashIcon } from "lucide-react";
import { ChevronDown, ChevronUp, HashIcon, Volume2Icon } from "lucide-react";
import { useState } from "react";
import AccountCard from "./AccountCard";
import { cn } from "@/lib/utils";
export function ChannelIcon({ kind }: { kind: ChannelKind["kind"] }) {
switch (kind) {
case "category":
return null;
case "text":
return <HashIcon className="p-0.5" />;
default:
return null;
case "voice":
return <Volume2Icon className="p-0.5" />;
}
}
+20
View File
@@ -0,0 +1,20 @@
import { invoke } from "@tauri-apps/api/core";
export type Config = {
outputDeviceName: string | null;
inputDeviceName: string | null;
inputVolume: number;
outputVolume: number;
};
export async function updateConfig(config: Config): Promise<void> {
await invoke("update_config", { config });
}
export async function saveConfig(): Promise<void> {
await invoke("save_config");
}
export async function getConfig(): Promise<Config> {
return await invoke("get_config");
}
+3 -1
View File
@@ -24,4 +24,6 @@ export interface StoredMessage extends MessageData {
export type Channel = { id: string; name: string } & ChannelKind;
export type ChannelKind =
{ kind: "text" } | { kind: "category"; channels: Channel[] };
| { kind: "category"; channels: Channel[] }
| { kind: "voice"; max_users: number }
| { kind: "text" };