`StreamWatch` asked "did this stream play as much audio as wall time passed?" by summing each frame's `Time<Real>` delta into `expected` and comparing it against the stream's own advanced position. But a Bevy clock delta describes the interval *ending* where the frame began, while `window_start` is stamped with `Instant::now()` part-way *through* a frame. So a window opened inside the 4 s world-entry frame was handed that entire 4 s by the next frame, as audible time that should have played — while the position, which tracks the speaker honestly, had advanced only by the window's real span. The difference was logged as injected silence. The tell was in the log all along: `position advanced` equalled the window `span` to the centisecond. When those two agree the audio was perfect, whatever `counted` says. All 101 "glue music stream starved" lines in every log to date are this artifact, plus 104 of 395 zone-music ones. Replaying the new formula over those logs silences all 205 and keeps all 291 real events — streams genuinely frozen with `position advanced 0.00 s`. Fixed by taking both ends of the window off one clock (`now - window_start`) and deleting the `dt` parameter outright, so the counted interval and the reported span are the same number by construction and no caller can supply an inconsistent delta. The old test `stream_watch_spans_the_window_it_counted` asserted exactly this invariant and passed, because its fixture fed a delta that agreed with its own wall clock — a fixture that supplies both sides of a relationship cannot test it. Replaced with the regression that matters: a stream which keeps up across a 4.1 s main-thread stall must not be reported, and one genuinely frozen through the same stall must still be named, or the fix has just deleted the meter. The warn now reports what it measured; "this is what a crackle sounds like" was read as evidence of a crackle and written into the defect backlog as one. `examples/stream_probe.rs` decodes a track through the chain exactly as kira's decoder does and compares the container's declared frame count against frames actually decoded — it killed the first hypothesis for the real dropout: 43 of 43 zone tracks are clean. The real dropout is still open and is not a crackle: one `alsa::poll()` POLLERR, which `output/cpal.rs` classifies as non-fatal and reports as "stream kept", and no sound advances again for the rest of the session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
149 lines
5.9 KiB
Rust
149 lines
5.9 KiB
Rust
//! `cargo run -p benilla-app --example stream_probe -- [<file-in-chain>…]` — does a streamed track
|
|
//! decode as many frames as it declares?
|
|
//!
|
|
//! The question behind the zone-music dropouts. A streaming sound's transport runs to the frame
|
|
//! count the container *declares* (`codec_params.n_frames`), while the audio it can actually play
|
|
//! is whatever the decoder yields. When the declaration overstates the content, the stream spends
|
|
//! the difference audible with nothing to play: `third_party/kira`'s decode scheduler asks
|
|
//! `frame_at_index` for a frame past the real end, which decodes chunk after chunk looking for an
|
|
//! index that will never arrive. The renderer's frame ring drains, the position stops advancing,
|
|
//! and — because no error was raised and the transport never reached its end — the sound stays
|
|
//! `Playing`, so benilla's reap-and-schedule-the-next-track pass never fires
|
|
//! (`sound/zone.rs`'s music transport). That is a silent music slot that only a zone change clears.
|
|
//!
|
|
//! Reads every copy through the chain, so it answers for the bytes the client actually plays
|
|
//! ([`benilla_formats::Chain`]), not a copy on disk. With no argument it probes the tracks named
|
|
//! on stdin, one chain path per line.
|
|
//!
|
|
//! Prints, per track: the declared frame count, the frames actually decoded, and the number of
|
|
//! chunks that came back empty on the way. `declared > decoded` is the defect; the empty-chunk
|
|
//! tally says whether the decoder stalls before it reports EOF.
|
|
|
|
use std::io::BufRead;
|
|
|
|
use symphonia::core::codecs::Decoder;
|
|
use symphonia::core::formats::FormatReader;
|
|
use symphonia::core::io::MediaSourceStream;
|
|
use symphonia::core::probe::Hint;
|
|
|
|
/// The decoder's own frame count for one buffer, matching kira's `load_frames_from_buffer_ref`.
|
|
fn frames_in(buf: &symphonia::core::audio::AudioBufferRef) -> usize {
|
|
use symphonia::core::audio::Signal;
|
|
match buf {
|
|
symphonia::core::audio::AudioBufferRef::U8(b) => b.frames(),
|
|
symphonia::core::audio::AudioBufferRef::U16(b) => b.frames(),
|
|
symphonia::core::audio::AudioBufferRef::U24(b) => b.frames(),
|
|
symphonia::core::audio::AudioBufferRef::U32(b) => b.frames(),
|
|
symphonia::core::audio::AudioBufferRef::S8(b) => b.frames(),
|
|
symphonia::core::audio::AudioBufferRef::S16(b) => b.frames(),
|
|
symphonia::core::audio::AudioBufferRef::S24(b) => b.frames(),
|
|
symphonia::core::audio::AudioBufferRef::S32(b) => b.frames(),
|
|
symphonia::core::audio::AudioBufferRef::F32(b) => b.frames(),
|
|
symphonia::core::audio::AudioBufferRef::F64(b) => b.frames(),
|
|
}
|
|
}
|
|
|
|
fn main() {
|
|
let Some(data) = benilla_formats::wow_data() else {
|
|
eprintln!("no install");
|
|
std::process::exit(1);
|
|
};
|
|
let chain = benilla_formats::Chain::open(&data).expect("chain opens");
|
|
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
let names: Vec<String> = if args.is_empty() {
|
|
std::io::stdin()
|
|
.lock()
|
|
.lines()
|
|
.map_while(Result::ok)
|
|
.map(|l| l.trim().to_string())
|
|
.filter(|l| !l.is_empty())
|
|
.collect()
|
|
} else {
|
|
args
|
|
};
|
|
|
|
println!(
|
|
"{:<58} {:>10} {:>10} {:>9} {:>6} verdict",
|
|
"track", "declared", "decoded", "short by", "empty"
|
|
);
|
|
let mut bad = 0usize;
|
|
for name in &names {
|
|
let path = name.replace('/', "\\");
|
|
let Ok(bytes) = chain.read(&path) else {
|
|
println!("{:<58} not in the chain", short(&path));
|
|
continue;
|
|
};
|
|
match probe(bytes) {
|
|
Err(e) => println!("{:<58} {e}", short(&path)),
|
|
Ok((declared, decoded, empty)) => {
|
|
let verdict = if declared > decoded {
|
|
bad += 1;
|
|
"DECLARES MORE THAN IT HAS"
|
|
} else {
|
|
"ok"
|
|
};
|
|
println!(
|
|
"{:<58} {declared:>10} {decoded:>10} {:>9} {empty:>6} {verdict}",
|
|
short(&path),
|
|
declared.saturating_sub(decoded),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
println!(
|
|
"\n{bad} of {} track(s) declare more frames than they decode",
|
|
names.len()
|
|
);
|
|
}
|
|
|
|
fn short(p: &str) -> String {
|
|
p.rsplit('\\').next().unwrap_or(p).to_string()
|
|
}
|
|
|
|
/// `(declared, decoded, empty_chunks)` for one track, decoded exactly as kira's decoder does.
|
|
fn probe(bytes: Vec<u8>) -> Result<(usize, usize, usize), String> {
|
|
let source = std::io::Cursor::new(bytes);
|
|
let mss = MediaSourceStream::new(Box::new(source), Default::default());
|
|
let probed = symphonia::default::get_probe()
|
|
.format(
|
|
&Hint::default(),
|
|
mss,
|
|
&Default::default(),
|
|
&Default::default(),
|
|
)
|
|
.map_err(|e| format!("probe: {e}"))?;
|
|
let mut format_reader: Box<dyn FormatReader> = probed.format;
|
|
let track = format_reader.default_track().ok_or("no default track")?;
|
|
let declared: usize = track
|
|
.codec_params
|
|
.n_frames
|
|
.ok_or("no declared frame count")?
|
|
.try_into()
|
|
.map_err(|_| "frame count does not fit usize")?;
|
|
let track_id = track.id;
|
|
let mut decoder: Box<dyn Decoder> = symphonia::default::get_codecs()
|
|
.make(&track.codec_params, &Default::default())
|
|
.map_err(|e| format!("codec: {e}"))?;
|
|
|
|
let mut decoded = 0usize;
|
|
let mut empty = 0usize;
|
|
// The read ends where kira's decoder would report an error, which is what stops the sound.
|
|
while let Ok(packet) = format_reader.next_packet() {
|
|
if packet.track_id() != track_id {
|
|
continue;
|
|
}
|
|
match decoder.decode(&packet) {
|
|
Ok(buf) => {
|
|
let n = frames_in(&buf);
|
|
if n == 0 {
|
|
empty += 1;
|
|
}
|
|
decoded += n;
|
|
}
|
|
Err(_) => empty += 1,
|
|
}
|
|
}
|
|
Ok((declared, decoded, empty))
|
|
}
|