1use cpal::traits::{DeviceTrait, HostTrait};
4
5use crate::Error;
6use crate::SAMPLE_RATE;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Device {
11 pub id: String,
17 pub name: String,
19 pub is_default: bool,
22}
23
24pub fn devices() -> Result<Vec<Device>, Error> {
29 let host = cpal::default_host();
30 let default = host.default_input_device().and_then(|d| d.id().ok());
31 let found =
32 host.input_devices().map_err(|error| Error::Capture { message: error.to_string() })?;
33
34 Ok(found
35 .filter_map(|device| {
36 let id = device.id().ok()?;
37 Some(record(&device, Some(&id), default.as_ref()))
38 })
39 .collect())
40}
41
42fn record(
45 device: &cpal::Device,
46 id: Option<&cpal::DeviceId>,
47 default: Option<&cpal::DeviceId>,
48) -> Device {
49 Device {
50 is_default: id.is_some() && id == default,
51 id: id.map(ToString::to_string).unwrap_or_default(),
52 name: device.to_string(),
53 }
54}
55
56pub(crate) fn describe(device: &cpal::Device) -> Device {
65 let default = cpal::default_host().default_input_device().and_then(|d| d.id().ok());
66 record(device, device.id().ok().as_ref(), default.as_ref())
67}
68
69pub(crate) fn open_device(wanted: Option<&str>) -> Result<cpal::Device, Error> {
75 let host = cpal::default_host();
76 let Some(wanted) = wanted else {
77 return host.default_input_device().ok_or(Error::NoInputDevice);
78 };
79 let found =
80 host.input_devices().map_err(|error| Error::Capture { message: error.to_string() })?;
81 for device in found {
82 if device.id().is_ok_and(|id| id.to_string() == wanted) {
83 return Ok(device);
84 }
85 }
86 Err(Error::DeviceNotFound {
87 wanted: wanted.to_owned(),
88 available: devices()?
89 .into_iter()
90 .map(|d| format!("{} ({})", d.id, d.name))
91 .collect::<Vec<_>>()
92 .join(", "),
93 })
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub(crate) enum InputFormat {
100 F32,
101 I16,
105}
106
107impl InputFormat {
108 fn of(format: cpal::SampleFormat) -> Option<Self> {
110 match format {
111 cpal::SampleFormat::F32 => Some(Self::F32),
112 cpal::SampleFormat::I16 => Some(Self::I16),
113 _ => None,
114 }
115 }
116}
117
118#[derive(Debug, Clone, PartialEq)]
122pub(crate) struct InputPlan {
123 pub(crate) config: cpal::StreamConfig,
124 pub(crate) sample_format: InputFormat,
127 pub(crate) resample_from: Option<u32>,
131}
132
133pub(crate) fn input_config(device: &cpal::Device) -> Result<InputPlan, Error> {
149 let supported = device
150 .supported_input_configs()
151 .map_err(|error| Error::Capture { message: error.to_string() })?;
152 input_plan(supported)
153}
154
155#[derive(Debug, Clone)]
157struct Candidate {
158 rank: (u16, u32, u8),
161 rate: cpal::SampleRate,
162 config: cpal::StreamConfig,
163 format: InputFormat,
164}
165
166impl Candidate {
167 fn new(
168 channels: u16,
169 distance: u32,
170 rate: cpal::SampleRate,
171 config: cpal::StreamConfig,
172 format: InputFormat,
173 ) -> Self {
174 Self {
175 rank: (channels, distance, u8::from(format != InputFormat::F32)),
176 rate,
177 config,
178 format,
179 }
180 }
181}
182
183fn input_plan(
186 supported: impl IntoIterator<Item = cpal::SupportedStreamConfigRange>,
187) -> Result<InputPlan, Error> {
188 let wanted: cpal::SampleRate = SAMPLE_RATE;
189 let mut offered = Vec::new();
190 let mut native: Option<Candidate> = None;
191 let mut fallback: Option<Candidate> = None;
192 for range in supported {
193 let reported = range.sample_format();
194 offered.push(format!(
195 "{}ch {}-{}Hz {:?}",
196 range.channels(),
197 range.min_sample_rate(),
198 range.max_sample_rate(),
199 reported
200 ));
201 let Some(format) = InputFormat::of(reported) else {
202 continue;
203 };
204 if let Some(config) = range.try_with_sample_rate(wanted) {
205 let config: cpal::StreamConfig = config.into();
206 let candidate = Candidate::new(config.channels, 0, wanted, config, format);
207 if native.as_ref().is_none_or(|best| candidate.rank < best.rank) {
208 native = Some(candidate);
209 }
210 continue;
211 }
212 let rate = SAMPLE_RATE.clamp(range.min_sample_rate(), range.max_sample_rate());
215 let config = cpal::StreamConfig {
216 channels: range.channels(),
217 sample_rate: rate,
218 buffer_size: cpal::BufferSize::Default,
219 };
220 let candidate =
221 Candidate::new(range.channels(), rate.abs_diff(SAMPLE_RATE), rate, config, format);
222 if fallback.as_ref().is_none_or(|best| candidate.rank < best.rank) {
223 fallback = Some(candidate);
224 }
225 }
226 if let Some(native) = native {
227 return Ok(InputPlan {
228 config: native.config,
229 sample_format: native.format,
230 resample_from: None,
231 });
232 }
233 fallback
234 .map(|candidate| InputPlan {
235 config: candidate.config,
236 sample_format: candidate.format,
237 resample_from: Some(candidate.rate),
238 })
239 .ok_or(Error::UnsupportedInput { wanted: SAMPLE_RATE, offered: offered.join(", ") })
240}
241
242#[cfg(test)]
243mod tests_input_config {
244 use super::*;
245 use cpal::{SampleFormat, SupportedBufferSize, SupportedStreamConfigRange};
246
247 fn range(
250 channels: u16,
251 min: u32,
252 max: u32,
253 format: SampleFormat,
254 ) -> SupportedStreamConfigRange {
255 SupportedStreamConfigRange::new(channels, min, max, SupportedBufferSize::Unknown, format)
256 }
257
258 #[test]
259 fn a_device_offering_16k_takes_the_native_path() {
260 let plan = input_plan([
263 range(2, 16_000, 16_000, SampleFormat::F32),
264 range(2, 24_000, 24_000, SampleFormat::F32),
265 range(2, 32_000, 32_000, SampleFormat::F32),
266 ])
267 .expect("a 16 kHz config is on offer");
268 assert_eq!(
269 plan.config.sample_rate, SAMPLE_RATE,
270 "the native path must open at the model rate"
271 );
272 assert_eq!(
273 plan.resample_from, None,
274 "a native 16 kHz config must not engage the resampler"
275 );
276 }
277
278 #[test]
279 fn a_device_without_16k_falls_back_to_its_best_f32_offer() {
280 let plan = input_plan([range(2, 48_000, 48_000, SampleFormat::F32)])
282 .expect("an F32 offer must be usable, not refused");
283 assert_eq!(
284 (plan.config.channels, plan.config.sample_rate),
285 (2, 48_000),
286 "the fallback opens the device's own best F32 config"
287 );
288 assert_eq!(
289 plan.resample_from,
290 Some(48_000),
291 "the samples arrive at 48 kHz and must be converted to the model rate"
292 );
293 }
294
295 #[test]
296 fn the_fallback_prefers_fewer_channels_then_the_rate_nearest_16k() {
297 let plan = input_plan([
298 range(2, 24_000, 24_000, SampleFormat::F32),
299 range(1, 44_100, 44_100, SampleFormat::F32),
300 ])
301 .expect("offers exist");
302 assert_eq!(
303 (plan.config.channels, plan.config.sample_rate),
304 (1, 44_100),
305 "fewest channels wins even when its rate is the farther one"
306 );
307
308 let plan = input_plan([
309 range(2, 48_000, 48_000, SampleFormat::F32),
310 range(2, 44_100, 44_100, SampleFormat::F32),
311 ])
312 .expect("offers exist");
313 assert_eq!(
314 plan.config.sample_rate, 44_100,
315 "equal channels: the rate nearest the model rate wins"
316 );
317 }
318
319 #[test]
320 fn an_i16_offer_opens_in_its_own_format() {
321 let plan = input_plan([range(2, 16_000, 16_000, SampleFormat::I16)])
325 .expect("an I16 offer is usable");
326 assert_eq!(plan.sample_format, InputFormat::I16, "the stream is built for I16");
327 assert_eq!((plan.config.channels, plan.config.sample_rate), (2, 16_000));
328 assert_eq!(plan.resample_from, None, "the native rate needs no resampling");
329 }
330
331 #[test]
332 fn an_i16_device_without_16k_still_takes_the_resample_fallback() {
333 let plan = input_plan([range(1, 48_000, 48_000, SampleFormat::I16)])
334 .expect("an I16 offer is usable");
335 assert_eq!(plan.sample_format, InputFormat::I16);
336 assert_eq!(plan.resample_from, Some(48_000), "the rate still goes through the resampler");
337 }
338
339 #[test]
340 fn a_device_with_no_offer_this_crate_can_take_names_the_format_first() {
341 let err = input_plan([range(2, 44_100, 44_100, SampleFormat::U8)])
346 .expect_err("no F32 or I16, no open");
347 let sentence = err.to_string();
348 assert!(
349 sentence.starts_with("the input device offers no F32 or I16 stream for 16000 Hz input"),
350 "the mismatch is the format first and the rate second: {sentence}"
351 );
352 assert!(
353 sentence.contains("2ch 44100-44100Hz U8"),
354 "the refusal must keep naming what the device offered, got: {sentence}"
355 );
356 match err {
357 Error::UnsupportedInput { wanted, .. } => assert_eq!(wanted, SAMPLE_RATE),
358 other => panic!("the no-format refusal must stay UnsupportedInput, got: {other:?}"),
359 }
360 }
361
362 #[test]
363 fn a_native_i16_offer_wins_over_an_f32_one_that_needs_converting() {
364 let plan = input_plan([
368 range(1, 16_000, 16_000, SampleFormat::I16),
369 range(2, 48_000, 48_000, SampleFormat::F32),
370 ])
371 .expect("both offers are usable");
372 assert_eq!(plan.resample_from, None, "a native 16 kHz stream is the one this crate opens");
373 assert_eq!((plan.config.channels, plan.config.sample_rate), (1, 16_000));
374 assert_eq!(plan.sample_format, InputFormat::I16);
375 }
376
377 #[test]
378 #[ignore = "enumerates the real audio devices; run with --run-ignored all on a machine with audio hardware"]
379 fn enumeration_names_at_most_one_default() {
380 let Ok(found) = devices() else { return };
384 let defaults = found.iter().filter(|d| d.is_default).count();
385 assert!(defaults <= 1, "at most one input can be the system default, found {defaults}");
386 for device in &found {
387 assert!(
388 !device.id.is_empty(),
389 "a device with no id cannot be persisted or re-resolved"
390 );
391 }
392 }
393
394 #[test]
395 #[ignore = "enumerates the real audio devices; run with --run-ignored all on a machine with audio hardware"]
396 fn a_named_device_that_is_absent_never_falls_back() {
397 let Ok(found) = devices() else { return };
403 if !found.iter().any(|d| d.is_default) {
404 return;
405 }
406 assert!(
407 open_device(Some("forge-capture-no-such-device")).is_err(),
408 "a named device that is not present must error, never quietly record on the default"
409 );
410 assert!(
411 open_device(None).is_ok(),
412 "naming nothing must still resolve to the system default"
413 );
414 }
415}