Add audio input device selector

Add a ComboBox in the top bar to select the audio input device at
runtime. Users with USB audio interfaces or multiple input devices
can now switch without changing their OS default prior to launching the app.

- Add selected_device_name field and switch_device()/
  available_input_devices()/selected_device_name() methods to Mir
- Refactor audio_input() to accept an optional device name
- Add "Audio input:" ComboBox in the top bar after latency compensation
- Cache the device list to avoid per-frame CoreAudio enumeration,
  refresh on ComboBox click to pick up newly connected devices

Signed-off-by: Christina Ying Wang <cywang117@outlook.com>
This commit is contained in:
Christina Ying Wang
2026-02-10 21:07:16 -08:00
parent 83af607354
commit b3747b4bf9
2 changed files with 106 additions and 7 deletions
+33
View File
@@ -131,6 +131,8 @@ struct App<'a> {
left_panel_expanded: bool,
library_newly_opened: bool,
insertion_point: InsertionPoint,
selected_device_name: String,
available_devices: Vec<String>,
preview_images: HashMap<NodeId, egui::TextureId>,
winit_output: WinitOutput<'a>,
app_ui: Option<AppUi>, // Stuff we can't make until we have a window
@@ -333,6 +335,8 @@ impl App<'_> {
left_panel_expanded: false,
library_newly_opened: false,
insertion_point: Default::default(),
selected_device_name: "Default".to_owned(),
available_devices: Mir::available_input_devices(),
preview_images: Default::default(),
winit_output,
app_ui: None,
@@ -798,6 +802,35 @@ impl App<'_> {
.suffix("s")
.range(0. ..=1.),
);
ui.label("Audio input:");
let prev_device = self.selected_device_name.clone();
let combo = egui::ComboBox::from_id_salt("audio input device")
.selected_text(self.selected_device_name.as_str())
.show_ui(ui, |ui| {
ui.selectable_value(
&mut self.selected_device_name,
"Default".to_owned(),
"Default",
);
for name in &self.available_devices {
ui.selectable_value(
&mut self.selected_device_name,
name.clone(),
name.as_str(),
);
}
});
if combo.response.clicked() {
self.available_devices = Mir::available_input_devices();
}
if self.selected_device_name != prev_device {
let device = if self.selected_device_name == "Default" {
None
} else {
Some(self.selected_device_name.clone())
};
self.mir.switch_device(device);
}
});
});
+73 -7
View File
@@ -1,6 +1,6 @@
pub use crate::beat_tracking::AudioLevels;
use crate::beat_tracking::{BeatTracker, N_FILTERS, SAMPLE_RATE};
use cpal::traits::DeviceTrait;
use cpal::traits::{DeviceTrait, HostTrait};
use std::sync::mpsc;
use std::time;
@@ -31,6 +31,7 @@ pub struct Mir {
last_update: Update,
pub global_timescale: f32,
pub latency_compensation: f32, // Anticipate beats by this many seconds
selected_device_name: Option<String>,
}
/// Updates sent over a queue
@@ -84,13 +85,29 @@ impl Default for Mir {
}
impl Mir {
fn audio_input(sender: mpsc::SyncSender<Update>) -> Result<cpal::Stream, String> {
use cpal::traits::{HostTrait, StreamTrait};
fn audio_input(
sender: mpsc::SyncSender<Update>,
device_name: Option<&str>,
) -> Result<cpal::Stream, String> {
use cpal::traits::StreamTrait;
let host = cpal::default_host();
let device = host
.default_input_device()
.ok_or("No audio input devices found")?;
let device = match device_name {
Some(name) => host
.input_devices()
.map_err(|e| format!("Failed to enumerate input devices: {:?}", e))?
.find(|d| {
d.description()
.ok()
.map(|desc| desc.name().to_string())
.as_deref()
== Some(name)
})
.ok_or_else(|| format!("Audio input device '{}' not found", name))?,
None => host
.default_input_device()
.ok_or("No audio input devices found")?,
};
const MIN_USEFUL_BUFFER_SIZE: cpal::FrameCount = 256; // Lower actually would be useful, but CPAL lies about the min size, so this ought to be safe
const SAMPLE_RATE_CPAL: cpal::SampleRate = SAMPLE_RATE as u32;
@@ -294,7 +311,7 @@ impl Mir {
let (sender, receiver) = mpsc::sync_channel(MESSAGE_BUFFER_SIZE);
// Set up system audio
let (stream, last_update) = match Self::audio_input(sender) {
let (stream, last_update) = match Self::audio_input(sender, None) {
Ok(stream) => (
Some(stream),
Update {
@@ -330,6 +347,55 @@ impl Mir {
last_update,
global_timescale: 1.,
latency_compensation: 0.1,
selected_device_name: None,
}
}
pub fn available_input_devices() -> Vec<String> {
let host = cpal::default_host();
match host.input_devices() {
Ok(devices) => devices
.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
.collect(),
Err(e) => {
println!("MIR: Failed to enumerate input devices: {:?}", e);
Vec::new()
}
}
}
pub fn selected_device_name(&self) -> &str {
match &self.selected_device_name {
Some(name) => name.as_str(),
None => "Default",
}
}
pub fn switch_device(&mut self, device_name: Option<String>) {
self.selected_device_name = device_name;
// Drop the current stream
self._stream = None;
// Create a new channel and stream
const MESSAGE_BUFFER_SIZE: usize = 16;
let (sender, receiver) = mpsc::sync_channel(MESSAGE_BUFFER_SIZE);
match Self::audio_input(sender, self.selected_device_name.as_deref()) {
Ok(stream) => {
println!(
"MIR: Switched to audio device: {}",
self.selected_device_name()
);
self._stream = Some(stream);
self.receiver = receiver;
}
Err(e) => {
println!("MIR: Failed to switch audio device: {}", e);
println!("MIR: Proceeding with no audio input");
self._stream = None;
self.receiver = receiver;
}
}
}