Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 83 additions & 19 deletions desktop/src-tauri/src/cmd/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,46 @@ pub fn get_audio_devices() -> Result<Vec<AudioDevice>> {
let host = cpal::default_host();
let mut audio_devices = Vec::new();

let default_in = host.default_input_device().map(|e| e.name()).context("name")?;
let default_out = host.default_output_device().map(|e| e.name()).context("name")?;
tracing::debug!("Default Input Device:\n{:?}", default_in);
tracing::debug!("Default Output Device:\n{:?}", default_out);
let default_in_name = host.default_input_device().and_then(|e| e.name().ok());
let default_out_name = host.default_output_device().and_then(|e| e.name().ok());
tracing::debug!("Default Input Device:\n{:?}", default_in_name);
tracing::debug!("Default Output Device:\n{:?}", default_out_name);

let devices = host.devices()?;
tracing::debug!("Devices: ");
for (device_index, device) in devices.enumerate() {
let name = device.name()?;
let is_default_in = default_in.as_ref().is_ok_and(|d| d == &name);
let is_default_out = default_out.as_ref().is_ok_and(|d| d == &name);
let pcm_id = match device.name() {
Ok(name) => name,
Err(e) => {
tracing::warn!("Skipping device {}: {}", device_index, e);
continue;
}
};

// On Linux/ALSA, only show "default" and "plughw:" devices.
// Raw "hw:" devices lack format conversion and often fail.
// Other virtual devices (dmix, dsnoop, surround, etc.) add clutter.
#[cfg(target_os = "linux")]
if pcm_id != "default" && !pcm_id.starts_with("plughw:") {
continue;
}

// Use description for a human-friendly name, fall back to pcm_id
let name = device
.description()
.ok()
.map(|d| d.name().to_string())
.unwrap_or_else(|| pcm_id.clone());

let is_default_in = default_in_name.as_ref().is_some_and(|d| d == &pcm_id);
let is_default_out = default_out_name.as_ref().is_some_and(|d| d == &pcm_id);

// "default" ALSA device has Unknown direction but supports both input and output
let is_input = device.supports_input() || pcm_id == "default";

let audio_device = AudioDevice {
is_default: is_default_in || is_default_out,
is_input: device.supports_input(),
is_default: is_default_in || is_default_out || pcm_id == "default",
is_input,
id: device_index.to_string(),
name,
};
Expand All @@ -70,6 +95,7 @@ pub async fn start_record(
let mut wav_paths: Vec<(PathBuf, u32)> = Vec::new();
let mut stream_handles = Vec::new();
let mut stream_writers = Vec::new();
let peak: PeakLevel = Arc::new(AtomicU32::new(0));

for device in devices {
tracing::debug!("Recording from device: {}", device.name);
Expand All @@ -79,7 +105,7 @@ pub async fn start_record(
let (device, config) = if is_input {
let device_id: usize = device.id.parse().context("Failed to parse device ID")?;
let dev = host.devices()?.nth(device_id).context("Failed to get device by ID")?;
let config = dev.default_input_config().context("Failed to get default input config")?;
let config = find_working_input_config(&dev)?;
(dev, config)
} else {
get_output_device_and_config(&host, &device)?
Expand All @@ -95,7 +121,7 @@ pub async fn start_record(
stream_writers.push(writer.clone());
let writer_2 = writer.clone();

let stream = build_input_stream(&device, config, writer_2)?;
let stream = build_input_stream(&device, config, writer_2, peak.clone())?;
stream.play()?;
tracing::debug!("Stream started playing");

Expand All @@ -104,8 +130,22 @@ pub async fn start_record(
tracing::debug!("Stream handle created");
}

// Emit audio amplitude at ~30fps for the visualizer
let emitter_stop = Arc::new(AtomicBool::new(false));
let emitter_stop_clone = emitter_stop.clone();
let peak_clone = peak.clone();
let app_emitter = app_handle.clone();
tokio::spawn(async move {
while !emitter_stop_clone.load(Ordering::Relaxed) {
tokio::time::sleep(std::time::Duration::from_millis(33)).await;
let level = f32::from_bits(peak_clone.load(Ordering::Relaxed));
let _ = app_emitter.emit("audio_amplitude", level);
}
});

let app_handle_clone = app_handle.clone();
app_handle.once("stop_record", move |_event| {
emitter_stop.store(true, Ordering::Relaxed);
for (i, stream_handle) in stream_handles.iter().enumerate() {
let stream_handle = stream_handle.lock().map_err(|e| eyre!("{:?}", e)).log_error();
if let Some(mut stream_handle) = stream_handle {
Expand Down Expand Up @@ -199,6 +239,22 @@ pub async fn start_record(
Ok(())
}

/// Try default input config first, then fall back to iterating supported configs.
/// On ALSA/PipeWire the default config can report parameters that fail at snd_pcm_hw_params.
fn find_working_input_config(device: &Device) -> Result<SupportedStreamConfig> {
if let Ok(config) = device.default_input_config() {
return Ok(config);
}
// Fall back: pick the first supported config, preferring lower sample rates
let mut configs: Vec<_> = device
.supported_input_configs()
.context("No supported input configs")?
.collect();
configs.sort_by_key(|c| c.min_sample_rate());
let range = configs.into_iter().next().context("No supported input configs available")?;
Ok(range.with_max_sample_rate())
}

#[allow(unused_variables)]
fn get_output_device_and_config(host: &cpal::Host, audio_device: &AudioDevice) -> Result<(Device, SupportedStreamConfig)> {
// On macOS, use the default output device directly — cpal's loopback support
Expand All @@ -219,25 +275,26 @@ fn get_output_device_and_config(host: &cpal::Host, audio_device: &AudioDevice) -
}
}

fn build_input_stream_typed<T>(device: &Device, config: SupportedStreamConfig, writer: WavWriterHandle) -> Result<Stream>
fn build_input_stream_typed<T>(device: &Device, config: SupportedStreamConfig, writer: WavWriterHandle, peak: PeakLevel) -> Result<Stream>
where
T: SizedSample + hound::Sample + FromSample<T> + Mul<Output = T> + Copy,
f32: FromSample<T>,
{
let stream = device.build_input_stream(
&config.into(),
move |data: &[T], _: &_| write_input_data::<T, T>(data, &writer),
move |data: &[T], _: &_| write_input_data::<T, T>(data, &writer, &peak),
|err| tracing::error!("An error occurred on stream: {}", err),
None,
)?;
Ok(stream)
}

fn build_input_stream(device: &Device, config: SupportedStreamConfig, writer: WavWriterHandle) -> Result<Stream> {
fn build_input_stream(device: &Device, config: SupportedStreamConfig, writer: WavWriterHandle, peak: PeakLevel) -> Result<Stream> {
match config.sample_format() {
cpal::SampleFormat::I8 => build_input_stream_typed::<i8>(device, config, writer),
cpal::SampleFormat::I16 => build_input_stream_typed::<i16>(device, config, writer),
cpal::SampleFormat::I32 => build_input_stream_typed::<i32>(device, config, writer),
cpal::SampleFormat::F32 => build_input_stream_typed::<f32>(device, config, writer),
cpal::SampleFormat::I8 => build_input_stream_typed::<i8>(device, config, writer, peak.clone()),
cpal::SampleFormat::I16 => build_input_stream_typed::<i16>(device, config, writer, peak.clone()),
cpal::SampleFormat::I32 => build_input_stream_typed::<i32>(device, config, writer, peak.clone()),
cpal::SampleFormat::F32 => build_input_stream_typed::<f32>(device, config, writer, peak.clone()),
sample_format => bail!("Unsupported sample format '{}'", sample_format),
}
}
Expand All @@ -260,18 +317,25 @@ fn wav_spec_from_config(config: &cpal::SupportedStreamConfig) -> hound::WavSpec
}

use std::ops::Mul;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};

type PeakLevel = Arc<AtomicU32>;

fn write_input_data<T, U>(input: &[T], writer: &WavWriterHandle)
fn write_input_data<T, U>(input: &[T], writer: &WavWriterHandle, peak: &PeakLevel)
where
T: Sample,
U: Sample + hound::Sample + FromSample<T> + Mul<Output = U> + Copy,
f32: FromSample<T>,
{
let mut max = 0f32;
if let Ok(mut guard) = writer.try_lock() {
if let Some(writer) = guard.as_mut() {
for &sample in input.iter() {
max = max.max(f32::from_sample(sample).abs());
let sample: U = U::from_sample(sample);
writer.write_sample(sample).ok();
}
}
}
peak.store(max.to_bits(), Ordering::Relaxed);
}
Loading