Skip to main content

flexaudio_mic/
lib.rs

1//! flexaudio-mic — cpal によるマイク入力バックエンド(全 OS)。
2//!
3//! [`CpalMicBackend`] は cpal で入力デバイスから生 interleaved `f32` フレームを取り、
4//! [`RawSink`] へ非ブロッキングに push する [`CaptureBackend`] 実装。検証は主に
5//! Linux/ALSA だが、cpal が対応する OS なら動く。
6//!
7//! # `cpal::Stream` が `!Send` なので所有スレッドへ閉じ込める
8//! [`CaptureBackend`] は `Send` を要求するが [`cpal::Stream`] は `!Send` なので、
9//! backend 構造体に直接持てない。[`start`](CpalMicBackend::start) でスレッドを
10//! spawn し、その中で stream を build + `play()` して停止シグナルまで `park` する。
11//! 停止時にそのスレッドが Stream を drop してキャプチャが止まる。構造体自身が持つのは
12//! `Send` なもの(停止フラグ・[`JoinHandle`]・キャッシュ済みフォーマット)だけ。
13//!
14//! ```no_run
15//! use flexaudio_mic::CpalMicBackend;
16//! use flexaudio_core::{CaptureBackend, RawSink, raw_ring};
17//!
18//! // 既定入力デバイス(device_id = None)。特定デバイスを選ぶなら
19//! // `CpalMicBackend::new(Some("デバイス名".into()))`(id = デバイス名)。
20//! let mut backend = CpalMicBackend::new(None);
21//! let (rate, channels) = backend.native_format();
22//! let (prod, _cons) = raw_ring(rate as usize * channels as usize); // 1 秒ぶん
23//! let sink = RawSink::new(prod, rate, channels);
24//! backend.start(sink).unwrap();
25//! // ... _cons から生フレームを pop ...
26//! backend.stop();
27//! ```
28
29#![warn(missing_docs)]
30
31use std::panic::{catch_unwind, AssertUnwindSafe};
32use std::sync::atomic::{AtomicBool, Ordering};
33use std::sync::mpsc;
34use std::sync::Arc;
35use std::thread::{self, JoinHandle};
36
37use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
38use cpal::{Device, SampleFormat};
39
40use flexaudio_core::backend::{CaptureBackend, RawSink};
41use flexaudio_core::clock::monotonic_now_ns;
42use flexaudio_core::types::{DeviceInfo, Error, Result, SourceKind};
43
44/// 入力デバイスが取れないとき [`native_format`](CpalMicBackend::native_format) が
45/// 返す既定フォーマット `(48000 Hz, mono)`。`start` 時にデバイスが無ければ
46/// [`Error::DeviceNotFound`] になる。
47const FALLBACK_FORMAT: (u32, u16) = (48_000, 1);
48
49/// cpal によるマイク入力キャプチャバックエンド。
50///
51/// 既定入力デバイス(`device_id = None`)か、デバイス名で選んだ入力デバイス
52/// (`device_id = Some(id)`)から生 interleaved `f32` フレームを取り [`RawSink`] へ
53/// 流す。詳細はモジュールドキュメント参照。
54///
55/// `Send`。持つのは停止フラグ・[`JoinHandle`]・キャッシュ済みフォーマット・
56/// device_id だけで、`!Send` な [`cpal::Stream`] は所有スレッド内に閉じ込める。
57pub struct CpalMicBackend {
58    /// 所有スレッドへの停止指示。`true` で stream を drop して終了する。
59    stop_flag: Arc<AtomicBool>,
60    /// cpal stream を所有するスレッドのハンドル(start 後に `Some`)。
61    handle: Option<JoinHandle<()>>,
62    /// `new` 時に問い合わせてキャッシュしたネイティブフォーマット。
63    native: (u32, u16),
64    /// 選択する入力デバイスの ID(デバイス名)。`None` で既定入力デバイス。
65    device_id: Option<String>,
66}
67
68impl CpalMicBackend {
69    /// マイクバックエンドを構築する。
70    ///
71    /// `device_id`:
72    /// - `None` → 既定入力デバイス(`host.default_input_device()`)。
73    /// - `Some(id)` → `host.input_devices()` を走査し `device.name()? == id` の最初の
74    ///   デバイス(id は [`list_devices`] が返すデバイス名)。
75    ///
76    /// 選んだデバイスのネイティブフォーマットを問い合わせてキャッシュする。デバイスが
77    /// 無い/一致しない/問い合わせ失敗なら `FALLBACK_FORMAT`(`(48000, 1)`)を
78    /// キャッシュする。new 自体は panic もエラーもせず必ず成功し、device_id が一致
79    /// しなければ [`start`](Self::start) で [`Error::DeviceNotFound`] になる。
80    pub fn new(device_id: Option<String>) -> Self {
81        let native = query_native_format(device_id.as_deref()).unwrap_or(FALLBACK_FORMAT);
82        Self {
83            stop_flag: Arc::new(AtomicBool::new(false)),
84            handle: None,
85            native,
86            device_id,
87        }
88    }
89}
90
91impl Default for CpalMicBackend {
92    fn default() -> Self {
93        Self::new(None)
94    }
95}
96
97/// `device_id` の入力デバイスを cpal ホストから解決する。
98///
99/// - `None` → `host.default_input_device()`(取れなければ [`Error::DeviceNotFound`])。
100/// - `Some(id)` → `host.input_devices()` を走査し `device.name()? == id` の最初の
101///   一致を返す。無ければ [`Error::DeviceNotFound`]。
102///
103/// 名前が取れないデバイスは比較できないのでスキップ。`input_devices()` 自体が失敗
104/// する環境(ALSA 不在等)も [`Error::DeviceNotFound`] に写す。
105fn resolve_input_device(host: &cpal::Host, device_id: Option<&str>) -> Result<Device> {
106    match device_id {
107        None => host.default_input_device().ok_or(Error::DeviceNotFound),
108        // デバイス名で一致する最初のデバイス。
109        Some(id) => {
110            let devices = host.input_devices().map_err(|_| Error::DeviceNotFound)?;
111            for device in devices {
112                // 名前が取れないデバイスは比較できないのでスキップ。
113                if let Ok(name) = device.name() {
114                    if name == id {
115                        return Ok(device);
116                    }
117                }
118            }
119            Err(Error::DeviceNotFound)
120        }
121    }
122}
123
124/// `device_id` で選択した入力デバイスのネイティブフォーマット
125/// `(sample_rate, channels)` を取得する。デバイス解決/設定取得に失敗すれば `None`
126/// (呼び元が [`FALLBACK_FORMAT`] へ落とす)。
127fn query_native_format(device_id: Option<&str>) -> Option<(u32, u16)> {
128    let host = cpal::default_host();
129    let device = resolve_input_device(&host, device_id).ok()?;
130    let config = device.default_input_config().ok()?;
131    Some((config.sample_rate().0, config.channels()))
132}
133
134/// 入力(マイク)デバイスを列挙する。`devices()` のマイク分。
135///
136/// `host.input_devices()` を走査し各デバイスを [`DeviceInfo`] へ写す:
137/// - `id` / `name`: cpal は永続 ID を持たないので device name を ID 代わりに両方へ
138///   入れる(再接続で index が変わるため。同一構成なら同じ name)。
139/// - `sample_rate` / `channels`: `default_input_config()` から。取れない(実際には
140///   開けない等)デバイスはスキップ。
141/// - `source_kind = Mic` / `is_loopback = false`。
142/// - `is_default`: `host.default_input_device()` の name と一致すれば `true`。
143///
144/// デバイスが無い/ホスト初期化失敗の環境では空 `Vec`(panic しない)。同名デバイス
145/// が複数あると id が重複し得るが、cpal でこれ以上安定なキーは取れないので許容する。
146pub fn list_devices() -> Result<Vec<DeviceInfo>> {
147    let host = cpal::default_host();
148
149    // 既定入力デバイス名(is_default 判定用)。取れなければ既定一致は付かない。
150    let default_name = host.default_input_device().and_then(|d| d.name().ok());
151
152    // input_devices() 自体が失敗する環境(ALSA 不在等)は空リスト扱い。
153    let devices = match host.input_devices() {
154        Ok(it) => it,
155        Err(_) => return Ok(Vec::new()),
156    };
157
158    let mut out = Vec::new();
159    for device in devices {
160        // name が取れないデバイスは ID を作れないのでスキップ。
161        let Ok(name) = device.name() else {
162            continue;
163        };
164        // 既定入力 config が取れない=広告されていても実際には開けない。スキップ。
165        let Ok(config) = device.default_input_config() else {
166            continue;
167        };
168        let is_default = default_name.as_deref() == Some(name.as_str());
169        out.push(DeviceInfo {
170            id: name.clone(),
171            name,
172            source_kind: SourceKind::Mic,
173            sample_rate: config.sample_rate().0,
174            channels: config.channels(),
175            is_loopback: false,
176            is_default,
177        });
178    }
179    Ok(out)
180}
181
182impl CaptureBackend for CpalMicBackend {
183    fn native_format(&self) -> (u32, u16) {
184        self.native
185    }
186
187    fn start(&mut self, sink: RawSink) -> Result<()> {
188        // 既に所有スレッドが生きていれば何もしない(二重 start に安全)。
189        if self.handle.is_some() {
190            return Ok(());
191        }
192        // 前回の stop 後でも再 start できるようフラグをリセット。
193        self.stop_flag.store(false, Ordering::SeqCst);
194
195        let stop_flag = self.stop_flag.clone();
196        // cpal::Device は !Send なので、device_id 文字列だけ渡してスレッド内で解決する。
197        let device_id = self.device_id.clone();
198        // build/play の成否を所有スレッドから start() へ返す ready channel。
199        let (ready_tx, ready_rx) = mpsc::channel::<Result<()>>();
200
201        let handle = thread::Builder::new()
202            .name("flexaudio-mic-cpal".into())
203            .spawn(move || {
204                run_capture_thread(sink, device_id, stop_flag, ready_tx);
205            })
206            .map_err(|e| Error::Backend(format!("spawn cpal mic thread: {e}")))?;
207
208        // 所有スレッドが stream を build + play できたか待つ。
209        match ready_rx.recv() {
210            Ok(Ok(())) => {
211                self.handle = Some(handle);
212                Ok(())
213            }
214            Ok(Err(e)) => {
215                // build/play 失敗。所有スレッドは ready 送信後に即終了するので join。
216                let _ = handle.join();
217                Err(e)
218            }
219            // ready 送信前に所有スレッドが死んだ(通常ありえない)。
220            Err(_) => {
221                let _ = handle.join();
222                Err(Error::Backend(
223                    "cpal mic thread exited before reporting readiness".into(),
224                ))
225            }
226        }
227    }
228
229    fn stop(&mut self) {
230        // handle が無ければ何もしない(再入・二重 stop に安全)。
231        self.stop_flag.store(true, Ordering::SeqCst);
232        if let Some(h) = self.handle.take() {
233            // 所有スレッドは park 中。unpark で起こし、Stream を drop させて終了。
234            h.thread().unpark();
235            let _ = h.join();
236        }
237    }
238}
239
240impl Drop for CpalMicBackend {
241    fn drop(&mut self) {
242        self.stop();
243    }
244}
245
246/// 所有スレッド本体。cpal input stream を build + play し、停止まで park する。
247///
248/// build/play の成否を `ready_tx` で [`CpalMicBackend::start`] へ報告する。成功後は
249/// `stop_flag` が立つまで park して `stream` を生かし、立ったら関数を抜けて
250/// `stream` を drop することでキャプチャを止める。
251fn run_capture_thread(
252    sink: RawSink,
253    device_id: Option<String>,
254    stop_flag: Arc<AtomicBool>,
255    ready_tx: mpsc::Sender<Result<()>>,
256) {
257    let stream = match build_stream(sink, device_id.as_deref()) {
258        Ok(s) => s,
259        Err(e) => {
260            // 失敗を報告して即終了。
261            let _ = ready_tx.send(Err(e));
262            return;
263        }
264    };
265
266    if let Err(e) = stream.play() {
267        let _ = ready_tx.send(Err(Error::Backend(format!("cpal play: {e}"))));
268        return;
269    }
270
271    // ここまで来れば起動成功。
272    let _ = ready_tx.send(Ok(()));
273
274    // stop シグナルまで stream を生かしたまま park する。
275    // 偽の wakeup に備え stop_flag を毎回確認する。
276    while !stop_flag.load(Ordering::SeqCst) {
277        thread::park();
278    }
279    // ここを抜けると stream が drop されキャプチャが停止する。
280    drop(stream);
281}
282
283/// RT 変換コールバックのスクラッチを事前確保するときに見込む最大ブロック長(秒)。
284///
285/// 1 コールバックの最大フレーム数を、ネイティブ SR×ch × この秒数で見積もって
286/// stream セットアップ時に確保する。実機のブロックは通常 数 ms〜数十 ms なので
287/// 1 秒ぶんあれば定常状態で容量拡大(= RT 内アロケート)は起きない。想定を超える
288/// 巨大ブロックが来ても、[`fill_scratch`] が一度だけ `reserve` で広げて以後その容量
289/// を保つ(panic しない)。
290const MAX_SCRATCH_SECONDS: usize = 1;
291
292/// 変換経路(I16/U16/I32)の RT コールバックで、interleaved 入力を変換しながら
293/// 事前確保済みスクラッチへ詰める。
294///
295/// `scratch` は stream セットアップ時に最大ブロック長で確保済み。定常状態では容量内
296/// なので `clear` + `push` は再確保を起こさない。`n` が容量を超えたときだけ一度
297/// `reserve` で広げ、以後その容量を保つ。`convert` を各サンプルへ適用する。
298#[inline]
299fn fill_scratch<T: Copy>(scratch: &mut Vec<f32>, data: &[T], convert: impl Fn(T) -> f32) {
300    let n = data.len();
301    // 容量内なら reserve は何もしない。容量超のときだけ一度広げる。
302    if n > scratch.capacity() {
303        scratch.reserve(n - scratch.capacity());
304    }
305    scratch.clear();
306    for &s in data {
307        scratch.push(convert(s));
308    }
309}
310
311/// プライミング過渡バッファと判定する f32 ピーク振幅の閾値。
312///
313/// flexaudio の f32 サンプルは契約上 `[-1.0, 1.0]`。Linux の PipeWire ALSA 互換
314/// ブリッジ(`default` PCM)は、冷えた状態で stream を開くと開始直後の数百 ms ぶん
315/// 範囲を大きく超えたフルスケール矩形のプライミング用ダミーバッファを吐く(実測ピーク
316/// ≈ 3.3、左右ほぼ逆相なので source 段では DC≈0 だが、下流のレート変換で巨大 DC と
317/// クリップに化ける)。正常音声は契約上 1.0 が上限なので、ピークが 1.0 を明確に超えた
318/// バッファを過渡とみなせる。±1.0 ちょうどの正常音声を巻き込まないよう 1.0 直上に置く。
319const PRIMING_PEAK_LIMIT: f32 = 1.001;
320
321/// キャプチャ開始直後のプライミング過渡バッファを破棄するガード。
322///
323/// 過渡バッファは `[-1.0, 1.0]` を超えるフルスケール矩形なので、ピークが
324/// [`PRIMING_PEAK_LIMIT`] を超えるバッファだけ捨てる。
325///
326/// 固定秒数で頭を捨てる方式は、過渡の無い環境(Mac / Windows / warm な Linux)でも
327/// 頭出し無音を作ってしまう。ピークだけ見て過渡か判定するので、過渡が無い環境では
328/// 1 バッファも捨てず、過渡の実長にも自動追従する。
329///
330/// 先頭側が過渡(範囲外)に見える間だけ捨て、レンジ内バッファが 1 つ来たら以後は
331/// 永久に通す(latch-open)。過渡は起動直後だけ現れて単調減衰するので途中で再発
332/// しない。RT コールバック内専用なので、判定はバッファ 1 走査の `abs` 比較だけ。
333struct TransientGuard {
334    /// 既に正常(レンジ内)バッファを通したか。`true` 以降は常に通す。
335    latched: bool,
336}
337
338impl TransientGuard {
339    fn new() -> Self {
340        Self { latched: false }
341    }
342
343    /// interleaved f32 バッファを与え、プライミング過渡(捨てるべき)なら `true`。
344    /// レンジ内バッファを 1 つでも通したら、以後は常に `false`(通す)。
345    fn should_drop(&mut self, data: &[f32]) -> bool {
346        if self.latched || data.is_empty() {
347            // 既に正常区間。または空バッファ(捨てる意味がない)。
348            self.latched = true;
349            return false;
350        }
351        // バッファ 1 走査でピーク振幅を求める。
352        let mut peak = 0.0f32;
353        for &s in data {
354            let a = s.abs();
355            if a > peak {
356                peak = a;
357            }
358        }
359        // 契約レンジを明確に超える=プライミング過渡。
360        let is_transient = peak > PRIMING_PEAK_LIMIT;
361        if !is_transient {
362            self.latched = true;
363        }
364        is_transient
365    }
366}
367
368/// `device_id` の入力デバイスへ input stream を build する(まだ `play` しない)。
369/// `device_id = None` で既定入力デバイス。一致するデバイスが無ければ
370/// [`Error::DeviceNotFound`]。
371///
372/// sample format ごとにコールバックを分岐し、F32 はそのまま、I16/U16/I32 は
373/// `f32` `[-1.0, 1.0]` へ変換して [`RawSink::push`] へ渡す。開始直後のプライミング
374/// 過渡バッファは [`TransientGuard`] が破棄する(PipeWire ALSA ブリッジ対策)。
375fn build_stream(sink: RawSink, device_id: Option<&str>) -> Result<cpal::Stream> {
376    let host = cpal::default_host();
377    // None=既定 / Some=name 一致の最初。不一致は DeviceNotFound。
378    let device = resolve_input_device(&host, device_id)?;
379
380    // 既定入力 config が取れない=広告されたデバイスが実際には開けない
381    // (サウンドカード無しのサーバ等で ALSA "default" PCM が開けない場合を含む)。
382    // 使える入力デバイスが無いのと等価なので DeviceNotFound に写す。
383    let supported = device
384        .default_input_config()
385        .map_err(|_| Error::DeviceNotFound)?;
386    let sample_format = supported.sample_format();
387    let config: cpal::StreamConfig = supported.into();
388
389    let err_fn = |e: cpal::StreamError| {
390        // RT 経路外のエラーコールバック。ログ手段が未配線なので今は黙殺する
391        // (TODO: 配線層で Event::DeviceLost 等へ写す)。
392        let _ = e;
393    };
394
395    // 変換経路のスクラッチを最大ブロック長(ネイティブ SR×ch × MAX_SCRATCH_SECONDS)
396    // で事前確保する。RT コールバック内の初回/拡大アロケート(xrun リスク)を定常状態
397    // で避けるため。最低 1 は確保する。
398    let scratch_cap = (config.sample_rate.0 as usize)
399        .saturating_mul(config.channels as usize)
400        .saturating_mul(MAX_SCRATCH_SECONDS)
401        .max(1);
402
403    // sink はコールバックへ move。F32 以外は変換用に閉じ込める。過渡判定は f32 値に
404    // 対して行うので、変換フォーマットでは変換後に判定する。
405    //
406    // cpal の data コールバックは FFI(C ABI)境界を越えて呼ばれるので、ここで panic
407    // すると未定義動作になり得る。今 live なパニック経路は無いが、念のため各コールバック
408    // 本体を catch_unwind で包み、万一の panic はその回のブロックを捨てるだけにする。
409    let stream = match sample_format {
410        SampleFormat::F32 => {
411            let mut sink = sink;
412            let mut guard = TransientGuard::new();
413            device.build_input_stream(
414                &config,
415                move |data: &[f32], _: &cpal::InputCallbackInfo| {
416                    let _ = catch_unwind(AssertUnwindSafe(|| {
417                        // 既に interleaved f32。プライミング過渡なら捨てる。
418                        if guard.should_drop(data) {
419                            return;
420                        }
421                        sink.push(data, monotonic_now_ns());
422                    }));
423                },
424                err_fn,
425                None,
426            )
427        }
428        SampleFormat::I16 => {
429            let mut sink = sink;
430            // 変換用スクラッチ。最大ブロック長で事前確保し、RT 内で容量拡大させない。
431            let mut scratch: Vec<f32> = Vec::with_capacity(scratch_cap);
432            let mut guard = TransientGuard::new();
433            device.build_input_stream(
434                &config,
435                move |data: &[i16], _: &cpal::InputCallbackInfo| {
436                    let _ = catch_unwind(AssertUnwindSafe(|| {
437                        fill_scratch(&mut scratch, data, |s| s as f32 / -(i16::MIN as f32));
438                        if guard.should_drop(&scratch) {
439                            return;
440                        }
441                        sink.push(&scratch, monotonic_now_ns());
442                    }));
443                },
444                err_fn,
445                None,
446            )
447        }
448        SampleFormat::U16 => {
449            let mut sink = sink;
450            let mut scratch: Vec<f32> = Vec::with_capacity(scratch_cap);
451            let mut guard = TransientGuard::new();
452            device.build_input_stream(
453                &config,
454                move |data: &[u16], _: &cpal::InputCallbackInfo| {
455                    let _ = catch_unwind(AssertUnwindSafe(|| {
456                        // u16 [0, 65535] を中点 32768 基準で [-1, 1) へ。
457                        fill_scratch(&mut scratch, data, |s| (s as f32 - 32_768.0) / 32_768.0);
458                        if guard.should_drop(&scratch) {
459                            return;
460                        }
461                        sink.push(&scratch, monotonic_now_ns());
462                    }));
463                },
464                err_fn,
465                None,
466            )
467        }
468        SampleFormat::I32 => {
469            let mut sink = sink;
470            let mut scratch: Vec<f32> = Vec::with_capacity(scratch_cap);
471            let mut guard = TransientGuard::new();
472            device.build_input_stream(
473                &config,
474                move |data: &[i32], _: &cpal::InputCallbackInfo| {
475                    let _ = catch_unwind(AssertUnwindSafe(|| {
476                        fill_scratch(&mut scratch, data, |s| s as f32 / -(i32::MIN as f32));
477                        if guard.should_drop(&scratch) {
478                            return;
479                        }
480                        sink.push(&scratch, monotonic_now_ns());
481                    }));
482                },
483                err_fn,
484                None,
485            )
486        }
487        other => {
488            return Err(Error::Backend(format!(
489                "unsupported cpal sample format: {other:?}"
490            )));
491        }
492    };
493
494    stream.map_err(|e| Error::Backend(format!("build_input_stream: {e}")))
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use flexaudio_core::raw_ring;
501
502    /// interleaved stereo の f32 バッファを `frames` フレームぶん生成する。
503    /// 各サンプルは交互に `±peak`(ピーク振幅 `peak` の矩形)。
504    fn make_buf(frames: usize, peak: f32) -> Vec<f32> {
505        let mut v = Vec::with_capacity(frames * 2);
506        for i in 0..frames {
507            let s = if i % 2 == 0 { peak } else { -peak };
508            v.push(s); // L
509            v.push(s); // R
510        }
511        v
512    }
513
514    /// [`TransientGuard`] が「範囲外フルスケール(過渡)→ 減衰 → レンジ内」の列の
515    /// 先頭側(ピークが [`PRIMING_PEAK_LIMIT`] 超のバッファ)だけを破棄し、レンジ内
516    /// バッファが来たら latch して以後は全て通すこと。
517    #[test]
518    fn transient_guard_drops_priming_then_latches_open() {
519        let frames = 1024;
520        let mut g = TransientGuard::new();
521
522        // 範囲外フルスケール矩形(実測のプライミング過渡相当 peak≈3.3)→ 破棄。
523        assert!(g.should_drop(&make_buf(frames, 3.3)));
524        // 減衰中だがまだ範囲外(peak=1.5 > LIMIT)→ 破棄。
525        assert!(g.should_drop(&make_buf(frames, 1.5)));
526        // レンジ内に戻った正常音声(peak=0.88)→ 通す=ここで latch。
527        assert!(!g.should_drop(&make_buf(frames, 0.88)));
528        // latch 後は、たとえ範囲外バッファが来ても以後は必ず通す(途中再発を防ぐ)。
529        assert!(!g.should_drop(&make_buf(frames, 3.3)));
530    }
531
532    /// 過渡が全く無い環境(Mac/Win/warm Linux)では先頭からレンジ内音声なので、
533    /// [`TransientGuard`] は 1 バッファも破棄しない(頭出し無音ゼロ)。
534    #[test]
535    fn transient_guard_passes_clean_audio_from_the_start() {
536        let frames = 1024;
537        let mut g = TransientGuard::new();
538        // デジタルフルスケール ±1.0 ちょうどでも誤検知しない(LIMIT が 1.0 直上)。
539        assert!(!g.should_drop(&make_buf(frames, 1.0)));
540        assert!(!g.should_drop(&make_buf(frames, 0.5)));
541        // 無音(全ゼロ)も破棄しない。
542        assert!(!g.should_drop(&vec![0.0f32; frames * 2]));
543    }
544
545    /// 空バッファは破棄せず latch する。
546    #[test]
547    fn transient_guard_handles_empty_buffer() {
548        let mut g = TransientGuard::new();
549        assert!(!g.should_drop(&[]));
550        // 空で latch したので、以後の範囲外バッファも通す。
551        assert!(!g.should_drop(&make_buf(1024, 3.3)));
552    }
553
554    /// [`fill_scratch`] が容量内では再確保を起こさず、変換も正しいこと。RT コール
555    /// バックでの定常状態アロケート無しを担保する。
556    #[test]
557    fn fill_scratch_no_realloc_in_steady_state() {
558        // 最大想定ブロック長で確保。
559        let cap = 480 * 2; // 10ms @ 48k stereo 相当
560        let mut scratch: Vec<f32> = Vec::with_capacity(cap);
561        let before = scratch.capacity();
562
563        // 容量内のブロックを何度詰めても容量は変わらない(= 再確保が起きない)。
564        let data: Vec<i16> = (0..cap as i16).collect();
565        for _ in 0..100 {
566            fill_scratch(&mut scratch, &data, |s| s as f32 / -(i16::MIN as f32));
567            assert_eq!(scratch.len(), data.len());
568            assert_eq!(scratch.capacity(), before, "定常状態で容量拡大しない");
569        }
570        // 変換が正しい(i16::MIN は -1.0 にマップ)。
571        let mut one = Vec::with_capacity(1);
572        fill_scratch(&mut one, &[i16::MIN], |s| s as f32 / -(i16::MIN as f32));
573        assert_eq!(one[0], -1.0);
574    }
575
576    /// `new` + `native_format` が panic しないこと(入力デバイス有無を問わず)。
577    /// device_id = None(既定)でも Some(特定デバイス)でも new は必ず成功する。
578    #[test]
579    fn new_and_native_format_do_not_panic() {
580        // 既定入力デバイス(device_id = None)。
581        let backend = CpalMicBackend::new(None);
582        let (rate, channels) = backend.native_format();
583        // フォーマットは常に正の値(デバイス無しなら FALLBACK_FORMAT)。
584        assert!(rate > 0);
585        assert!(channels > 0);
586
587        // 存在しない device_id でも new は panic せず成功し、FALLBACK_FORMAT を返す
588        // (解決失敗の表面化は start/build_stream まで遅延する設計)。
589        let backend = CpalMicBackend::new(Some("__no_such_device__".into()));
590        let (rate, channels) = backend.native_format();
591        assert_eq!((rate, channels), FALLBACK_FORMAT);
592    }
593
594    /// 存在しない device_id を指定した `start` は panic せず
595    /// [`Error::DeviceNotFound`] になる(cold-start/TransientGuard と整合)。
596    /// 既定入力デバイスの有無に依らず、不一致 id は必ず DeviceNotFound。
597    #[test]
598    fn start_with_unknown_device_id_yields_device_not_found() {
599        let mut backend = CpalMicBackend::new(Some("__no_such_device__".into()));
600        let (rate, channels) = backend.native_format();
601        let cap = (rate as usize * channels as usize).max(1);
602        let (prod, _cons) = raw_ring(cap);
603        let sink = RawSink::new(prod, rate, channels);
604
605        match backend.start(sink) {
606            Err(Error::DeviceNotFound) => {}
607            other => panic!("unknown device_id は DeviceNotFound であるべき: {other:?}"),
608        }
609    }
610
611    /// [`list_devices`] はデバイス有無を問わず panic せず `Ok(Vec)` を返す。
612    /// 返ったデバイスは全て `Mic` / 非ループバックで、`id == name` の安定キーを持つ。
613    #[test]
614    fn list_devices_never_panics_and_is_consistent() {
615        let devices = list_devices().expect("list_devices は Err を返さない設計");
616        for d in &devices {
617            assert_eq!(d.source_kind, SourceKind::Mic);
618            assert!(!d.is_loopback, "マイクはループバックではない");
619            // 安定キー: cpal では id にデバイス名を使う。
620            assert_eq!(d.id, d.name);
621            assert!(!d.id.is_empty(), "id(=name)は空でない");
622            assert!(d.sample_rate > 0);
623            assert!(d.channels > 0);
624        }
625        // 既定入力は高々 1 つ。
626        assert!(devices.iter().filter(|d| d.is_default).count() <= 1);
627    }
628
629    /// `start` は入力デバイスが無い環境(サーバー・CI 等)では `Err(DeviceNotFound)` に
630    /// なり得る。Ok と Err(DeviceNotFound) の両方を許容し、panic だけは不可。
631    /// 入力デバイスがある環境では実際にキャプチャが起動し、stop で停止する。
632    #[test]
633    fn start_then_stop_tolerates_missing_device() {
634        let mut backend = CpalMicBackend::new(None);
635        let (rate, channels) = backend.native_format();
636        let cap = (rate as usize * channels as usize).max(1); // 約 1 秒
637        let (prod, _cons) = raw_ring(cap);
638        let sink = RawSink::new(prod, rate, channels);
639
640        match backend.start(sink) {
641            Ok(()) => {
642                // 起動できた環境では停止が安全に行えること。
643                backend.stop();
644                // 二重 stop も安全。
645                backend.stop();
646            }
647            Err(Error::DeviceNotFound) => {
648                // 入力デバイス無し環境(CI/サーバ)では許容。
649            }
650            Err(other) => panic!("unexpected error from start(): {other:?}"),
651        }
652    }
653
654    /// 実マイクから実際に録音する end-to-end テスト。入力デバイスのある
655    /// ラップトップ等で `cargo test -p flexaudio-mic -- --ignored` で回す。
656    /// サーバ/CI には入力デバイスが無いため既定では `#[ignore]`。
657    #[test]
658    #[ignore = "実マイク必須。ラップトップで `cargo test -p flexaudio-mic -- --ignored` で実行"]
659    fn end_to_end_captures_real_audio() {
660        use std::time::Duration;
661
662        let mut backend = CpalMicBackend::new(None);
663        let (rate, channels) = backend.native_format();
664        let cap = rate as usize * channels as usize * 2; // 約 2 秒
665        let (prod, mut cons) = raw_ring(cap);
666        let sink = RawSink::new(prod, rate, channels);
667
668        backend
669            .start(sink)
670            .expect("start() should succeed with a real input device");
671
672        // 数百ミリ秒キャプチャしてサンプルが流れてくることを確認。
673        thread::sleep(Duration::from_millis(500));
674        backend.stop();
675
676        let mut buf = vec![0.0f32; cap];
677        let got = cons.pop_slice(&mut buf);
678        assert!(got > 0, "expected captured samples, got none");
679        // サンプルは [-1, 1] の範囲内に収まること(変換の健全性)。
680        assert!(buf[..got].iter().all(|&s| (-1.5..=1.5).contains(&s)));
681    }
682}