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}