Skip to main content

forge_capture/
device.rs

1//! The inputs a host can record from, and the plan one opens at.
2
3use cpal::traits::{DeviceTrait, HostTrait};
4
5use crate::Error;
6use crate::SAMPLE_RATE;
7
8/// An input the host can offer a user, and record from.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Device {
11    /// Stable identity, and the value a host persists. cpal documents
12    /// this as the thing to store and re-resolve, which is why the
13    /// selection is keyed on it rather than on `name`: names collide
14    /// between two identical interfaces and change when a user renames
15    /// one, and neither should silently move the recording.
16    pub id: String,
17    /// Human label for a picker. Not an identity.
18    pub name: String,
19    /// Whether the system would pick this one when asked for no
20    /// particular device.
21    pub is_default: bool,
22}
23
24/// Every input the host could record from.
25///
26/// The crate never chooses: a caller that names nothing gets the system
27/// default, and one that names a device gets that device or an error.
28pub 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
42/// One device as the host describes it: the id a host persists, the label a
43/// picker shows, and whether the system would pick it unaided.
44fn 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
56/// The host's own record of a device already resolved: its id, its label,
57/// and whether the system would have picked it unaided.
58///
59/// A capture carries this back to its caller, which is how a host names the
60/// input the system default resolved to - the picker's list itself carries
61/// no default mark. A device whose id cannot be read is still described: the
62/// label is what a host draws, and refusing the take over an unreadable id
63/// would lose the recording rather than just its name.
64pub(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
69/// Resolve a caller's choice to a cpal device.
70///
71/// A named device that has gone is an error rather than a fallback:
72/// someone who chose a USB interface and finds it unplugged needs to
73/// know, not to be quietly recorded on the built-in microphone.
74pub(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/// The sample formats the capture can open. Anything else is refused at the
97/// plan rather than when the stream is built.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub(crate) enum InputFormat {
100    F32,
101    /// Converted to f32 as it arrives: the downstream pipeline reads f32, and
102    /// several devices - Android's `aaudio` inputs among them - offer only
103    /// this one.
104    I16,
105}
106
107impl InputFormat {
108    /// The format `cpal` reports as one this capture can take.
109    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/// How a capture opens: the stream config the device will run at, the format
119/// its stream delivers, and the rate its samples arrive at when that is not
120/// already [`SAMPLE_RATE`].
121#[derive(Debug, Clone, PartialEq)]
122pub(crate) struct InputPlan {
123    pub(crate) config: cpal::StreamConfig,
124    /// The format the stream is built for; the planner never yields one the
125    /// capture cannot open.
126    pub(crate) sample_format: InputFormat,
127    /// `Some` when the device cannot offer [`SAMPLE_RATE`] itself and
128    /// its audio needs converting; `None` when the samples flow
129    /// through untouched.
130    pub(crate) resample_from: Option<u32>,
131}
132
133/// Pick an input the models can read.
134///
135/// A device offering [`SAMPLE_RATE`] opens natively, fewest channels winning:
136/// less to average, less to go wrong. F32 is preferred at equal channels
137/// because it skips the conversion, but I16 is taken where that is all the
138/// device has. One that cannot serve the model rate still opens its best
139/// config - fewest channels, then the rate nearest the model rate - and its
140/// audio is downmixed and resampled with a real filter downstream. Measured
141/// on the target hardware, which offers 16, 24 and 32 kHz and never fewer
142/// than two channels - so requiring mono here would have meant no microphone
143/// at all - and on USB microphones that offer only 48 kHz.
144///
145/// What is still refused is a device offering neither F32 nor I16: the error
146/// names the formats the capture takes, then everything the device offered,
147/// so the mismatch is legible as the format it is.
148pub(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/// One openable offer, scored the way the negotiation chooses between them.
156#[derive(Debug, Clone)]
157struct Candidate {
158    /// Channels first, then the distance from the model rate, and only then
159    /// the format, where F32 wins a tie because it needs no conversion.
160    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
183/// The negotiation over synthetic ranges, so a device is not needed to
184/// test it.
185fn 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        // The range cannot serve the model rate itself, so its nearest
213        // end is the resample candidate.
214        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    /// A cpal range with no device behind it. The negotiation reads
248    /// only these fields, so this is a complete synthetic device.
249    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        // The Mac's built-in input measured inventory: 16 kHz exists,
261        // but only in stereo beside 24 and 32.
262        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        // The AirHug 28's whole inventory, the device this case exists for.
281        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        // The Android rig's aaudio inputs: cpal reports them I16-only, and
322        // the capture used to refuse every one while the picker listed them
323        // (#2362). I16 is converted to the f32 the pipeline reads.
324        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        // Nothing the capture can open: the refusal names the format the
342        // capture takes and then the rate it needed, because the mismatch a
343        // reader has to chase is the format - the old sentence named the rate
344        // alone and sent them after the wrong problem.
345        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        // Both are openable now, and the native rate is the better stream:
365        // no resampling, and mono costs less to downmix. An F32 offer at a
366        // rate that needs conversion must not beat it.
367        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        // No audio hardware in CI, so an empty list is a valid answer;
381        // what must never happen is two devices both claiming default,
382        // which would make a host's picker ambiguous.
383        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        // Skipped where there is no audio stack at all, since then the
398        // absence proves nothing about the resolution path.
399        // Guarded on a DEFAULT existing, not merely on the list being
400        // non-empty: the second assertion needs `default_input_device()`
401        // to be Some, which is a different cpal call.
402        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}