use std::collections::VecDeque;
use std::panic::AssertUnwindSafe;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use flexaudio_core::backend::{CaptureBackend, RawSink};
use flexaudio_core::chunk_ring::{chunk_ring, ChunkConsumer, ChunkProducer};
use flexaudio_core::clock::{monotonic_now_ns, ClockNormalizer};
use flexaudio_core::normalizer::Normalizer;
use flexaudio_core::raw_ring::{raw_ring, RawConsumer};
use flexaudio_core::types::{
AudioChunk, ChunkFlags, Error, Event, OutputFormat, Result, StreamConfig,
};
pub(crate) const RAW_RING_SAMPLES: usize = 48_000;
const WATCHDOG_TICK: Duration = Duration::from_millis(250);
const STALL_THRESHOLD: Duration = Duration::from_secs(2);
const BACKOFF_MIN: Duration = Duration::from_millis(250);
const BACKOFF_MAX: Duration = Duration::from_secs(5);
pub struct Stream {
config: StreamConfig,
shared: Arc<SharedState>,
chunk_consumer: ChunkConsumer,
events: Arc<Mutex<VecDeque<Event>>>,
worker: Option<JoinHandle<()>>,
watchdog: Option<JoinHandle<()>>,
started: bool,
}
struct SharedState {
backend: Mutex<Box<dyn CaptureBackend>>,
raw_consumer: Mutex<Option<RawConsumer>>,
raw_generation: AtomicU64,
last_sample_ns: AtomicI64,
stopping: AtomicBool,
recovered_pending: AtomicBool,
events: Arc<Mutex<VecDeque<Event>>>,
chunk_producer: Mutex<Option<ChunkProducer>>,
native_format: Mutex<(u32, u16)>,
switching: AtomicBool,
discontinuity_pending: AtomicBool,
paused: AtomicBool,
gain_bits: AtomicU32,
}
impl SharedState {
fn push_event(&self, ev: Event) {
let mut q = self.events.lock().unwrap_or_else(|e| e.into_inner());
q.push_back(ev);
}
}
fn start_backend_catching(be: &mut Box<dyn CaptureBackend>, sink: RawSink) -> Result<()> {
match std::panic::catch_unwind(AssertUnwindSafe(|| be.start(sink))) {
Ok(res) => res,
Err(_) => Err(Error::Backend("backend panicked during start()".into())),
}
}
#[must_use]
fn stop_backend_catching(be: &mut Box<dyn CaptureBackend>) -> bool {
std::panic::catch_unwind(AssertUnwindSafe(|| be.stop())).is_ok()
}
impl Stream {
pub fn open(config: StreamConfig, backend: Box<dyn CaptureBackend>) -> Result<Stream> {
if config.ring_capacity_chunks == 0 {
return Err(Error::InvalidArg("ring_capacity_chunks must be > 0".into()));
}
if !config.gain.is_finite() || config.gain < 0.0 {
return Err(Error::InvalidArg(format!(
"gain must be finite and >= 0.0, got {}",
config.gain
)));
}
config.output.validate()?;
let native_format = backend.native_format();
if native_format.0 == 0 || native_format.1 == 0 {
return Err(Error::InvalidArg(
"backend native_format must have non-zero rate and channels".into(),
));
}
let (chunk_producer, chunk_consumer) = chunk_ring(config.ring_capacity_chunks);
let events = Arc::new(Mutex::new(VecDeque::new()));
let shared = Arc::new(SharedState {
backend: Mutex::new(backend),
raw_consumer: Mutex::new(None),
raw_generation: AtomicU64::new(0),
last_sample_ns: AtomicI64::new(0),
stopping: AtomicBool::new(false),
recovered_pending: AtomicBool::new(false),
events: events.clone(),
chunk_producer: Mutex::new(Some(chunk_producer)),
native_format: Mutex::new(native_format),
switching: AtomicBool::new(false),
discontinuity_pending: AtomicBool::new(false),
paused: AtomicBool::new(false),
gain_bits: AtomicU32::new(config.gain.to_bits()),
});
Ok(Stream {
config,
shared,
chunk_consumer,
events,
worker: None,
watchdog: None,
started: false,
})
}
pub fn start(&mut self) -> Result<()> {
if self.started {
return Ok(());
}
self.shared.stopping.store(false, Ordering::SeqCst);
self.shared.paused.store(false, Ordering::SeqCst);
Self::open_backend_once(&self.shared)?;
let chunk_producer = self
.shared
.chunk_producer
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
.ok_or_else(|| Error::InvalidState("chunk producer already taken".into()))?;
let worker_shared = self.shared.clone();
let initial_native = *self
.shared
.native_format
.lock()
.unwrap_or_else(|e| e.into_inner());
let output = self.config.output;
let worker = thread::Builder::new()
.name("flexaudio-intake".into())
.spawn(move || {
run_intake(worker_shared, chunk_producer, initial_native, output);
})
.map_err(|e| Error::Backend(format!("spawn intake thread: {e}")))?;
self.worker = Some(worker);
let wd_shared = self.shared.clone();
let watchdog = thread::Builder::new()
.name("flexaudio-watchdog".into())
.spawn(move || {
run_watchdog(wd_shared);
})
.map_err(|e| Error::Backend(format!("spawn watchdog thread: {e}")))?;
self.watchdog = Some(watchdog);
self.started = true;
Ok(())
}
pub fn stop(&mut self) {
self.shared.stopping.store(true, Ordering::SeqCst);
{
let mut be = self
.shared
.backend
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ = stop_backend_catching(&mut be);
}
if let Some(h) = self.worker.take() {
let _ = h.join();
}
if let Some(h) = self.watchdog.take() {
let _ = h.join();
}
self.started = false;
}
pub fn pause(&self) {
self.shared.paused.store(true, Ordering::SeqCst);
}
pub fn resume(&self) {
if self.shared.paused.swap(false, Ordering::SeqCst) {
self.shared
.discontinuity_pending
.store(true, Ordering::SeqCst);
}
}
pub fn is_paused(&self) -> bool {
self.shared.paused.load(Ordering::SeqCst)
}
pub fn set_gain(&self, gain: f32) -> Result<()> {
if !gain.is_finite() || gain < 0.0 {
return Err(Error::InvalidArg(format!(
"gain must be finite and >= 0.0, got {gain}"
)));
}
self.shared
.gain_bits
.store(gain.to_bits(), Ordering::Relaxed);
Ok(())
}
pub fn gain(&self) -> f32 {
f32::from_bits(self.shared.gain_bits.load(Ordering::Relaxed))
}
pub fn poll_chunk(&mut self) -> Option<AudioChunk> {
self.chunk_consumer.try_pop()
}
pub fn poll_event(&mut self) -> Option<Event> {
self.events.lock().ok().and_then(|mut q| q.pop_front())
}
pub fn dropped_chunks(&self) -> u64 {
self.chunk_consumer.dropped_count()
}
pub fn config(&self) -> &StreamConfig {
&self.config
}
pub fn native_format(&self) -> (u32, u16) {
*self
.shared
.native_format
.lock()
.unwrap_or_else(|e| e.into_inner())
}
fn open_backend_once(shared: &Arc<SharedState>) -> Result<()> {
let (rate, channels) = {
let be = shared.backend.lock().unwrap_or_else(|e| e.into_inner());
be.native_format()
};
{
let mut nf = shared
.native_format
.lock()
.unwrap_or_else(|e| e.into_inner());
*nf = (rate, channels);
}
let (producer, consumer) = raw_ring(RAW_RING_SAMPLES);
let sink = RawSink::new(producer, rate, channels);
{
let mut be = shared.backend.lock().unwrap_or_else(|e| e.into_inner());
start_backend_catching(&mut be, sink)?;
}
{
let mut rc = shared
.raw_consumer
.lock()
.unwrap_or_else(|e| e.into_inner());
*rc = Some(consumer);
}
shared.raw_generation.fetch_add(1, Ordering::SeqCst);
shared
.last_sample_ns
.store(monotonic_now_ns(), Ordering::SeqCst);
Ok(())
}
#[doc(hidden)]
pub fn switch_backend(&mut self, new_backend: Box<dyn CaptureBackend>) -> Result<()> {
if !self.started {
return Err(Error::InvalidState(
"switch_backend は start 済みのストリームでのみ可能".into(),
));
}
self.shared.switching.store(true, Ordering::SeqCst);
{
let mut be = self
.shared
.backend
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ = stop_backend_catching(&mut be);
let (rate, channels) = new_backend.native_format();
let (producer, consumer) = raw_ring(RAW_RING_SAMPLES);
let sink = RawSink::new(producer, rate, channels);
let mut new_backend = new_backend;
match start_backend_catching(&mut new_backend, sink) {
Ok(()) => {
{
let mut nf = self
.shared
.native_format
.lock()
.unwrap_or_else(|e| e.into_inner());
*nf = (rate, channels);
}
self.shared
.discontinuity_pending
.store(true, Ordering::SeqCst);
self.shared
.last_sample_ns
.store(monotonic_now_ns(), Ordering::SeqCst);
self.shared.raw_generation.fetch_add(1, Ordering::SeqCst);
*be = new_backend;
{
let mut rc = self
.shared
.raw_consumer
.lock()
.unwrap_or_else(|e| e.into_inner());
*rc = Some(consumer);
}
}
Err(e) => {
drop(be);
let _ = Self::open_backend_once(&self.shared);
self.shared
.discontinuity_pending
.store(true, Ordering::SeqCst);
self.shared.switching.store(false, Ordering::SeqCst);
return Err(e);
}
}
}
self.shared.switching.store(false, Ordering::SeqCst);
Ok(())
}
pub fn switch_source(&mut self, new_config: StreamConfig) -> Result<()> {
if !self.started {
return Err(Error::InvalidState(
"switch_source は start 済みのストリームでのみ可能".into(),
));
}
if new_config.output != self.config.output {
return Err(Error::InvalidArg(
"output format cannot change during switch_source".into(),
));
}
let backend = crate::build_backend(&new_config)?;
self.switch_backend(backend)?;
self.config = StreamConfig {
kind: new_config.kind,
device_id: new_config.device_id,
target_pid: new_config.target_pid,
mode: new_config.mode,
exclude_self: new_config.exclude_self,
..self.config.clone()
};
Ok(())
}
}
impl Drop for Stream {
fn drop(&mut self) {
if self.started {
self.stop();
}
}
}
fn run_intake(
shared: Arc<SharedState>,
mut chunk_producer: ChunkProducer,
initial_native: (u32, u16),
output: OutputFormat,
) {
let (mut rate, mut channels) = initial_native;
let mut normalizer = match Normalizer::new(rate, channels, output) {
Ok(n) => n,
Err(e) => {
shared.push_event(Event::Error(format!("normalizer init failed: {e}")));
return;
}
};
let mut clock = ClockNormalizer::new();
let mut seq: u64 = 0;
let mut current_generation = shared.raw_generation.load(Ordering::SeqCst);
let mut scratch = vec![0.0f32; RAW_RING_SAMPLES];
loop {
if shared.stopping.load(Ordering::SeqCst) {
break;
}
let gen = shared.raw_generation.load(Ordering::SeqCst);
if gen != current_generation {
current_generation = gen;
let nf = *shared
.native_format
.lock()
.unwrap_or_else(|e| e.into_inner());
rate = nf.0;
channels = nf.1;
normalizer = match Normalizer::new(rate, channels, output) {
Ok(n) => n,
Err(e) => {
shared.push_event(Event::Error(format!(
"normalizer rebuild failed after source change: {e}"
)));
return;
}
};
clock = ClockNormalizer::new();
}
let mut produced_any = false;
let mut push_err: Option<Error> = None;
{
let mut rc_guard = shared
.raw_consumer
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some(rc) = rc_guard.as_mut() {
let got = rc.pop_slice(&mut scratch);
if got > 0 {
let samples = &scratch[..got];
let device_pts = monotonic_now_ns();
let norm_pts = clock.normalize(device_pts);
if let Err(e) = normalizer.push(samples, norm_pts) {
push_err = Some(e);
} else {
shared
.last_sample_ns
.store(monotonic_now_ns(), Ordering::SeqCst);
produced_any = true;
}
}
}
}
if let Some(e) = push_err {
shared.push_event(Event::Error(format!("normalizer push failed: {e}")));
return;
}
let out_channels = output.channels.max(1) as usize;
let mut emitted_any = false;
while let Some((mut data, pts_ns)) = normalizer.pop_chunk() {
if shared.paused.load(Ordering::SeqCst) {
continue;
}
debug_assert_eq!(data.len() % out_channels, 0);
let frames = data.len() / out_channels;
let gain = f32::from_bits(shared.gain_bits.load(Ordering::Relaxed));
if gain != 1.0 {
for x in data.iter_mut() {
*x = (*x * gain).clamp(-1.0, 1.0);
}
}
let (peak, rms) = peak_rms(&data);
let mut flags = ChunkFlags::empty();
if shared.recovered_pending.swap(false, Ordering::SeqCst) {
flags |= ChunkFlags::RECOVERED | ChunkFlags::DISCONTINUITY;
}
if shared.discontinuity_pending.swap(false, Ordering::SeqCst) {
flags |= ChunkFlags::DISCONTINUITY;
}
let chunk = AudioChunk {
data,
frames,
pts_ns,
seq,
flags,
dropped_before: 0, peak,
rms,
};
seq += 1;
if let Some(total) = chunk_producer.push(chunk) {
shared.push_event(Event::ChunkDropped { count: total });
}
emitted_any = true;
}
if !produced_any && !emitted_any {
thread::sleep(Duration::from_millis(2));
}
}
}
fn run_watchdog(shared: Arc<SharedState>) {
let mut stalled = false;
let mut backoff = BACKOFF_MIN;
loop {
if shared.stopping.load(Ordering::SeqCst) {
break;
}
thread::sleep(WATCHDOG_TICK);
if shared.stopping.load(Ordering::SeqCst) {
break;
}
if shared.switching.load(Ordering::SeqCst) {
continue;
}
let now = monotonic_now_ns();
let last = shared.last_sample_ns.load(Ordering::SeqCst);
let idle_ns = now.saturating_sub(last);
let idle = Duration::from_nanos(idle_ns.max(0) as u64);
if !stalled {
if idle >= STALL_THRESHOLD {
stalled = true;
backoff = BACKOFF_MIN;
shared.push_event(Event::StreamStalled);
}
continue;
}
{
let mut be = shared.backend.lock().unwrap_or_else(|e| e.into_inner());
let _ = stop_backend_catching(&mut be);
}
if shared.stopping.load(Ordering::SeqCst) {
break;
}
let reopened = match Stream::open_backend_once(&shared) {
Ok(()) => true,
Err(e) => {
shared.push_event(Event::Error(format!("reopen failed: {e}")));
false
}
};
if reopened {
shared.recovered_pending.store(true, Ordering::SeqCst);
stalled = false;
shared.push_event(Event::StreamRecovered);
backoff = BACKOFF_MIN;
} else {
let jittered = jittered_backoff(backoff);
sleep_interruptible(&shared, jittered);
backoff = (backoff * 2).min(BACKOFF_MAX);
}
}
}
fn peak_rms(data: &[f32]) -> (f32, f32) {
if data.is_empty() {
return (0.0, 0.0);
}
let mut peak = 0.0f32;
let mut sum_sq = 0.0f64;
for &x in data {
let a = x.abs();
if a > peak {
peak = a;
}
sum_sq += (x as f64) * (x as f64);
}
let rms = (sum_sq / data.len() as f64).sqrt() as f32;
(peak, rms)
}
fn jittered_backoff(base: Duration) -> Duration {
let base_ns = base.as_nanos() as u64;
let entropy = monotonic_now_ns() as u64;
let span = (base_ns / 8).max(1);
let delta = (entropy % (2 * span)) as i64 - span as i64;
let result = base_ns as i64 + delta;
Duration::from_nanos(result.max(0) as u64)
}
fn sleep_interruptible(shared: &Arc<SharedState>, dur: Duration) {
let step = Duration::from_millis(50);
let mut remaining = dur;
while remaining > Duration::ZERO {
if shared.stopping.load(Ordering::SeqCst) {
return;
}
let s = step.min(remaining);
thread::sleep(s);
remaining = remaining.saturating_sub(s);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mock::{
MockBackend, PanicMode, PanickingMockBackend, StallThenPanicOnReopenBackend,
StallableMockBackend,
};
use std::time::Instant;
fn collect_for(stream: &mut Stream, dur: Duration) -> Vec<AudioChunk> {
let mut chunks = Vec::new();
let start = Instant::now();
while start.elapsed() < dur {
while let Some(c) = stream.poll_chunk() {
chunks.push(c);
}
thread::sleep(Duration::from_millis(5));
}
chunks
}
fn wait_until<F: FnMut() -> bool>(mut cond: F, timeout: Duration) -> bool {
let start = Instant::now();
while start.elapsed() < timeout {
if cond() {
return true;
}
thread::sleep(Duration::from_millis(10));
}
cond()
}
fn open_err(result: Result<Stream>, ctx: &str) -> Error {
match result {
Ok(_) => panic!("{ctx}: エラーを期待したが Ok だった"),
Err(e) => e,
}
}
#[test]
fn open_rejects_zero_ring_capacity() {
let backend = Box::new(MockBackend::new(48_000, 2, 440.0));
let config = StreamConfig {
ring_capacity_chunks: 0,
..Default::default()
};
let err = open_err(Stream::open(config, backend), "容量 0");
assert!(
matches!(err, Error::InvalidArg(_)),
"InvalidArg のはず: {err:?}"
);
}
#[test]
fn open_rejects_invalid_output_channels() {
let backend = Box::new(MockBackend::new(48_000, 2, 440.0));
let config = StreamConfig {
output: OutputFormat {
sample_rate: 48_000,
channels: 3,
},
..Default::default()
};
let err = open_err(Stream::open(config, backend), "ch=3");
assert!(
matches!(err, Error::UnsupportedFormat(_)),
"UnsupportedFormat のはず: {err:?}"
);
}
#[test]
fn open_rejects_out_of_range_output_rate() {
let backend = Box::new(MockBackend::new(48_000, 2, 440.0));
let config = StreamConfig {
output: OutputFormat {
sample_rate: 1_000_000,
channels: 2,
},
..Default::default()
};
let err = open_err(Stream::open(config, backend), "極端なレート");
assert!(
matches!(err, Error::UnsupportedFormat(_)),
"UnsupportedFormat のはず: {err:?}"
);
}
#[test]
fn open_rejects_zero_native_format() {
struct ZeroFormatBackend;
impl CaptureBackend for ZeroFormatBackend {
fn native_format(&self) -> (u32, u16) {
(0, 0)
}
fn start(&mut self, _sink: RawSink) -> Result<()> {
Ok(())
}
fn stop(&mut self) {}
}
let backend = Box::new(ZeroFormatBackend);
let err = open_err(
Stream::open(StreamConfig::default(), backend),
"native_format 0",
);
assert!(
matches!(err, Error::InvalidArg(_)),
"InvalidArg のはず: {err:?}"
);
}
#[test]
fn poll_event_yields_chunk_dropped() {
let backend = Box::new(MockBackend::new(48_000, 2, 440.0));
let config = StreamConfig {
ring_capacity_chunks: 1,
..Default::default()
};
let mut stream = Stream::open(config, backend).expect("open");
stream.start().expect("start");
let got_drop = wait_until(
|| {
while let Some(ev) = stream.poll_event() {
if matches!(ev, Event::ChunkDropped { .. }) {
return true;
}
}
false
},
Duration::from_secs(3),
);
stream.stop();
assert!(got_drop, "poll_event で ChunkDropped を取得できるはず");
}
#[test]
fn poll_event_is_none_when_empty() {
let backend = Box::new(MockBackend::new(48_000, 2, 440.0));
let mut stream = Stream::open(StreamConfig::default(), backend).expect("open");
assert!(stream.poll_event().is_none());
}
#[test]
fn watchdog_detects_stall_and_flags_recovered() {
let backend = Box::new(StallableMockBackend::new(
48_000,
2,
440.0,
Duration::from_millis(300),
));
let mut stream = Stream::open(StreamConfig::default(), backend).expect("open");
stream.start().expect("start");
let mut chunks: Vec<AudioChunk> = Vec::new();
let mut saw_stalled = false;
let mut saw_recovered = false;
let deadline = Instant::now() + Duration::from_secs(8);
let mut recovered_chunk_seen = false;
while Instant::now() < deadline && !recovered_chunk_seen {
while let Some(c) = stream.poll_chunk() {
if c.flags.contains(ChunkFlags::RECOVERED) {
recovered_chunk_seen = true;
}
chunks.push(c);
}
while let Some(ev) = stream.poll_event() {
match ev {
Event::StreamStalled => saw_stalled = true,
Event::StreamRecovered => saw_recovered = true,
_ => {}
}
}
thread::sleep(Duration::from_millis(20));
}
stream.stop();
while let Some(c) = stream.poll_chunk() {
if c.flags.contains(ChunkFlags::RECOVERED) {
recovered_chunk_seen = true;
}
chunks.push(c);
}
assert!(saw_stalled, "Event::StreamStalled が発火するはず");
assert!(saw_recovered, "Event::StreamRecovered が発火するはず");
assert!(
recovered_chunk_seen,
"復帰後の最初のチャンクに RECOVERED が立つはず"
);
let recovered: Vec<&AudioChunk> = chunks
.iter()
.filter(|c| c.flags.contains(ChunkFlags::RECOVERED))
.collect();
assert!(!recovered.is_empty());
for c in &recovered {
assert!(
c.flags.contains(ChunkFlags::DISCONTINUITY),
"RECOVERED には DISCONTINUITY が伴うはず: flags={:?}",
c.flags
);
}
for w in chunks.windows(2) {
assert!(
w[1].seq > w[0].seq,
"seq は復帰をまたいでも単調増加: {} -> {}",
w[0].seq,
w[1].seq
);
}
}
#[test]
fn no_recovered_flag_under_steady_feed() {
let backend = Box::new(MockBackend::new(48_000, 2, 440.0));
let mut stream = Stream::open(StreamConfig::default(), backend).expect("open");
stream.start().expect("start");
let chunks = collect_for(&mut stream, Duration::from_millis(500));
let mut saw_stalled = false;
while let Some(ev) = stream.poll_event() {
if matches!(ev, Event::StreamStalled) {
saw_stalled = true;
}
}
stream.stop();
assert!(!chunks.is_empty(), "安定給餌でチャンクが来るはず");
assert!(!saw_stalled, "安定給餌では失速判定されないはず");
for c in &chunks {
assert!(
!c.flags.contains(ChunkFlags::RECOVERED),
"安定給餌では RECOVERED は立たない: flags={:?}",
c.flags
);
}
}
#[test]
fn pause_stops_delivering_chunks() {
let backend = Box::new(MockBackend::new(48_000, 2, 440.0));
let mut stream = Stream::open(StreamConfig::default(), backend).expect("open");
stream.start().expect("start");
let got_before = wait_until(|| stream.poll_chunk().is_some(), Duration::from_secs(2));
assert!(got_before, "ポーズ前にチャンクが届くはず");
stream.pause();
while stream.poll_chunk().is_some() {}
let after = collect_for(&mut stream, Duration::from_millis(300));
stream.stop();
assert!(
after.is_empty(),
"ポーズ中は新しいチャンクが届かないはず: {} 個届いた",
after.len()
);
}
#[test]
fn long_pause_does_not_trigger_stall() {
let backend = Box::new(MockBackend::new(48_000, 2, 440.0));
let mut stream = Stream::open(StreamConfig::default(), backend).expect("open");
stream.start().expect("start");
let got_before = wait_until(|| stream.poll_chunk().is_some(), Duration::from_secs(2));
assert!(got_before, "ポーズ前にチャンクが届くはず");
stream.pause();
while stream.poll_chunk().is_some() {}
let mut saw_stalled = false;
let mut saw_recovered = false;
let deadline = Instant::now() + Duration::from_millis(2800);
while Instant::now() < deadline {
while let Some(ev) = stream.poll_event() {
match ev {
Event::StreamStalled => saw_stalled = true,
Event::StreamRecovered => saw_recovered = true,
_ => {}
}
}
assert!(
stream.is_paused(),
"ポーズ窓の間は is_paused が true のはず"
);
thread::sleep(Duration::from_millis(20));
}
assert!(
!saw_stalled,
"長いポーズでも StreamStalled は発火しないはず"
);
assert!(
!saw_recovered,
"失速していないので StreamRecovered も発火しないはず"
);
stream.resume();
let resumed = wait_until(|| stream.poll_chunk().is_some(), Duration::from_secs(2));
stream.stop();
assert!(resumed, "resume 後にチャンク配信が再開するはず");
}
#[test]
fn resume_flags_discontinuity_and_keeps_seq_continuous() {
let backend = Box::new(MockBackend::new(48_000, 2, 440.0));
let mut stream = Stream::open(StreamConfig::default(), backend).expect("open");
stream.start().expect("start");
let before = collect_for(&mut stream, Duration::from_millis(200));
assert!(!before.is_empty(), "ポーズ前にチャンクが届くはず");
let last_seq = before.last().unwrap().seq;
stream.pause();
let mut last_seq = last_seq;
while let Some(c) = stream.poll_chunk() {
last_seq = c.seq;
}
assert!(collect_for(&mut stream, Duration::from_millis(150)).is_empty());
stream.resume();
let mut first_after: Option<AudioChunk> = None;
let got = wait_until(
|| match stream.poll_chunk() {
Some(c) => {
first_after = Some(c);
true
}
None => false,
},
Duration::from_secs(2),
);
stream.stop();
assert!(got, "resume 後にチャンクが届くはず");
let first = first_after.unwrap();
assert!(
first.flags.contains(ChunkFlags::DISCONTINUITY),
"resume 後の最初のチャンクに DISCONTINUITY が立つはず: flags={:?}",
first.flags
);
assert_eq!(
first.seq,
last_seq + 1,
"seq はポーズ前後で連続するはず({last_seq} -> {})",
first.seq
);
assert_eq!(first.dropped_before, 0, "ポーズで取りこぼしは出ないはず");
}
#[test]
fn resume_without_pause_is_noop() {
let backend = Box::new(MockBackend::new(48_000, 2, 440.0));
let mut stream = Stream::open(StreamConfig::default(), backend).expect("open");
stream.start().expect("start");
let _ = collect_for(&mut stream, Duration::from_millis(200));
stream.resume();
let after = collect_for(&mut stream, Duration::from_millis(200));
stream.stop();
assert!(!after.is_empty(), "チャンクが届くはず");
for c in &after {
assert!(
!c.flags.contains(ChunkFlags::DISCONTINUITY),
"ポーズなしの resume では DISCONTINUITY は立たない: flags={:?}",
c.flags
);
}
}
#[test]
fn double_pause_then_single_resume_recovers() {
let backend = Box::new(MockBackend::new(48_000, 2, 440.0));
let mut stream = Stream::open(StreamConfig::default(), backend).expect("open");
stream.start().expect("start");
let before = collect_for(&mut stream, Duration::from_millis(200));
assert!(!before.is_empty(), "ポーズ前にチャンクが届くはず");
stream.pause();
stream.pause();
assert!(stream.is_paused());
while stream.poll_chunk().is_some() {}
assert!(collect_for(&mut stream, Duration::from_millis(150)).is_empty());
stream.resume();
assert!(!stream.is_paused());
let got = wait_until(|| stream.poll_chunk().is_some(), Duration::from_secs(2));
stream.stop();
assert!(got, "resume 一回で配信が再開するはず");
}
#[test]
fn gain_scales_samples_and_meters() {
for (gain, lo, hi) in [(2.0f32, 0.95f32, 1.0f32), (0.5, 0.2, 0.3)] {
let backend = Box::new(MockBackend::new(48_000, 2, 440.0));
let config = StreamConfig {
gain,
..Default::default()
};
let mut stream = Stream::open(config, backend).expect("open");
stream.start().expect("start");
let chunks = collect_for(&mut stream, Duration::from_millis(300));
stream.stop();
assert!(!chunks.is_empty(), "gain={gain} でチャンクが届くはず");
let mut max_peak = 0.0f32;
for c in &chunks {
let recomputed = c.data.iter().fold(0.0f32, |m, &x| m.max(x.abs()));
assert_eq!(
c.peak, recomputed,
"peak はゲイン適用後の data から算出されるはず"
);
max_peak = max_peak.max(c.peak);
}
assert!(
(lo..=hi).contains(&max_peak),
"gain={gain} のピークは {lo}..={hi} のはず: {max_peak}"
);
}
}
#[test]
fn set_gain_takes_effect_mid_stream() {
let backend = Box::new(MockBackend::new(48_000, 2, 440.0));
let mut stream = Stream::open(StreamConfig::default(), backend).expect("open");
stream.start().expect("start");
assert_eq!(stream.gain(), 1.0, "既定ゲインは 1.0");
let got_before = wait_until(|| stream.poll_chunk().is_some(), Duration::from_secs(2));
assert!(got_before, "set_gain 前にチャンクが届くはず");
stream.set_gain(0.0).expect("set_gain(0.0)");
assert_eq!(stream.gain(), 0.0);
let got_silent = wait_until(
|| matches!(stream.poll_chunk(), Some(c) if c.peak == 0.0),
Duration::from_secs(2),
);
assert!(got_silent, "set_gain(0.0) 後に無音チャンクが届くはず");
let after = collect_for(&mut stream, Duration::from_millis(300));
stream.stop();
assert!(!after.is_empty(), "無音でもチャンクは流れ続けるはず");
for c in &after {
assert!(
c.data.iter().all(|&x| x == 0.0),
"gain 0.0 では全サンプル 0 のはず"
);
assert_eq!(c.peak, 0.0);
assert_eq!(c.rms, 0.0);
}
}
#[test]
fn gain_clamps_to_unit_range() {
let backend = Box::new(MockBackend::new(48_000, 2, 440.0));
let config = StreamConfig {
gain: 100.0,
..Default::default()
};
let mut stream = Stream::open(config, backend).expect("open");
stream.start().expect("start");
let chunks = collect_for(&mut stream, Duration::from_millis(300));
stream.stop();
assert!(!chunks.is_empty(), "チャンクが届くはず");
let mut max_peak = 0.0f32;
for c in &chunks {
assert!(
c.data.iter().all(|&x| (-1.0..=1.0).contains(&x)),
"サンプルは ±1.0 を超えないはず"
);
max_peak = max_peak.max(c.peak);
}
assert_eq!(max_peak, 1.0, "クランプによりピークはちょうど 1.0 のはず");
}
#[test]
fn invalid_gain_rejected() {
let backend = Box::new(MockBackend::new(48_000, 2, 440.0));
let config = StreamConfig {
gain: -1.0,
..Default::default()
};
let err = open_err(Stream::open(config, backend), "gain=-1.0");
assert!(
matches!(err, Error::InvalidArg(_)),
"InvalidArg のはず: {err:?}"
);
let backend = Box::new(MockBackend::new(48_000, 2, 440.0));
let config = StreamConfig {
gain: f32::NAN,
..Default::default()
};
let err = open_err(Stream::open(config, backend), "gain=NaN");
assert!(
matches!(err, Error::InvalidArg(_)),
"InvalidArg のはず: {err:?}"
);
let backend = Box::new(MockBackend::new(48_000, 2, 440.0));
let stream = Stream::open(StreamConfig::default(), backend).expect("open");
assert!(matches!(stream.set_gain(-1.0), Err(Error::InvalidArg(_))));
assert!(matches!(
stream.set_gain(f32::NAN),
Err(Error::InvalidArg(_))
));
assert_eq!(
stream.gain(),
1.0,
"失敗した set_gain は現在値を変えないはず"
);
}
#[test]
fn backend_panic_in_start_returns_err_not_silent_death() {
let backend = Box::new(PanickingMockBackend::new(
48_000,
2,
440.0,
PanicMode::Start,
));
let mut stream = Stream::open(StreamConfig::default(), backend).expect("open");
let result = stream.start();
match result {
Ok(()) => panic!("backend が start で panic したのに start() が Ok を返した"),
Err(Error::Backend(msg)) => {
assert!(
msg.contains("panicked"),
"Error::Backend は panic 由来と分かるメッセージのはず: {msg}"
);
}
Err(other) => panic!("Error::Backend を期待したが別のエラー: {other:?}"),
}
stream.stop();
}
#[test]
fn backend_panic_in_stop_does_not_kill_process() {
let backend = Box::new(PanickingMockBackend::new(48_000, 2, 440.0, PanicMode::Stop));
let mut stream = Stream::open(StreamConfig::default(), backend).expect("open");
stream.start().expect("start");
let chunks = collect_for(&mut stream, Duration::from_millis(300));
assert!(
!chunks.is_empty(),
"stop 前に通常どおりチャンクが流れるはず(happy path 不変)"
);
stream.stop();
let _ = stream.poll_chunk();
let _ = stream.poll_event();
}
#[test]
fn backend_panic_on_watchdog_reopen_surfaces_event_error() {
let backend = Box::new(StallThenPanicOnReopenBackend::new(
48_000,
2,
440.0,
Duration::from_millis(300),
));
let mut stream = Stream::open(StreamConfig::default(), backend).expect("open");
stream.start().expect("start");
let mut saw_stalled = false;
let mut saw_reopen_error = false;
let deadline = Instant::now() + Duration::from_secs(8);
while Instant::now() < deadline && !saw_reopen_error {
while stream.poll_chunk().is_some() {}
while let Some(ev) = stream.poll_event() {
match ev {
Event::StreamStalled => saw_stalled = true,
Event::Error(msg) if msg.contains("reopen failed") => {
saw_reopen_error = true;
}
_ => {}
}
}
thread::sleep(Duration::from_millis(20));
}
stream.stop();
assert!(saw_stalled, "失速は検知されるはず(Event::StreamStalled)");
assert!(
saw_reopen_error,
"再オープンでの backend panic は Event::Error(\"reopen failed: ...\") として\
表に出るはず(無言死しない)"
);
}
}