Files
benilla-octo/crates/benilla-app/examples/stream_probe.rs
T
gromandClaude Opus 5 510dda3d3c sound: the glue-music crackle never happened — a meter that summed frame deltas
`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
2026-11-01 11:27:18 -07:00

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))
}