added full control over which input device to use in voice chat (limited by cpal)

This commit is contained in:
2026-01-30 15:49:48 +01:00
parent 81d9624c9e
commit c57e8d8254
11 changed files with 329 additions and 116 deletions

View File

@@ -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<Host, String> {
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<AudioState>) -> 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<String> {
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<String>) -> Result<Vec<(String, String)>, 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<AudioState>,
host_name: Option<String>,
device_id: Option<String>,
) -> 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<AudioState>) -> 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<u8>| {
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<AudioState>) -> 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();

View File

@@ -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");

View File

@@ -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",