diff --git a/crates/sylpheed-formats/examples/slb_fmt_probe.rs b/crates/sylpheed-formats/examples/slb_fmt_probe.rs new file mode 100644 index 00000000..60a34ada --- /dev/null +++ b/crates/sylpheed-formats/examples/slb_fmt_probe.rs @@ -0,0 +1,113 @@ +//! Does the `.slb` leading region decode as XMA1 under ANY plausible format? +//! +//! A first attempt at this hand-rolled the `fmt ` chunk and produced 0 PCM bytes +//! for all 36 combinations — including ones that should have matched the crate's +//! own working format. So it tested the chunk construction, not the hypothesis. +//! This version replicates `slb::synth_xma1_fmt`'s exact byte layout and starts +//! by reproducing its known result as a CONTROL; if the control does not match, +//! nothing below it means anything. +use std::io::Write; +use std::process::{Command, Stdio}; + +use sylpheed_formats::{movie_subtitle, slb, PakArchive}; + +/// Byte-for-byte `slb::synth_xma1_fmt` (private there). Note the second +/// parameter is a **channel mask**, not a stream count — mistaking it is what +/// made the first probe meaningless. +fn xma1_fmt(channels: u8, channel_mask: u16, rate: u32) -> Vec { + let mut fmt = Vec::with_capacity(40); + fmt.extend_from_slice(b"fmt "); + fmt.extend_from_slice(&32u32.to_le_bytes()); + fmt.extend_from_slice(&0x0165u16.to_le_bytes()); // XMA1 + fmt.extend_from_slice(&16u16.to_le_bytes()); // BitsPerSample + fmt.extend_from_slice(&0u16.to_le_bytes()); // EncodeOptions + fmt.extend_from_slice(&0u16.to_le_bytes()); // LargestSkip + fmt.extend_from_slice(&1u16.to_le_bytes()); // NumStreams + fmt.push(0); // LoopCount + fmt.push(3); // Version + fmt.extend_from_slice(&(rate * channels as u32 * 2).to_le_bytes()); + fmt.extend_from_slice(&rate.to_le_bytes()); + fmt.extend_from_slice(&0u32.to_le_bytes()); + fmt.extend_from_slice(&0u32.to_le_bytes()); + fmt.push(4); // SubframeData + fmt.push(channels); + fmt.extend_from_slice(&channel_mask.to_le_bytes()); + fmt +} + +fn riff(fmt: &[u8], data: &[u8]) -> Vec { + let mut out = Vec::with_capacity(12 + fmt.len() + 8 + data.len()); + out.extend_from_slice(b"RIFF"); + out.extend_from_slice(&((4 + fmt.len() + 8 + data.len()) as u32).to_le_bytes()); + out.extend_from_slice(b"WAVE"); + out.extend_from_slice(fmt); + out.extend_from_slice(b"data"); + out.extend_from_slice(&(data.len() as u32).to_le_bytes()); + out.extend_from_slice(data); + out +} + +fn decode_bytes(r: &[u8]) -> usize { + let Ok(mut c) = Command::new("ffmpeg") + .args(["-v", "error", "-i", "pipe:0", "-f", "s16le", "pipe:1"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + else { + return 0; + }; + let buf = r.to_vec(); + let mut stdin = c.stdin.take().unwrap(); + std::thread::spawn(move || { + let _ = stdin.write_all(&buf); + }); + c.wait_with_output().map(|o| o.stdout.len()).unwrap_or(0) +} + +fn main() { + let disc = std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC"); + let snd = PakArchive::open(format!("{disc}/dat/sound.pak")).expect("sound.pak"); + let lang = PakArchive::open(format!("{disc}/dat/movie/eng.pak")).expect("eng.pak"); + + // The leading region decodes only as MONO. At channels=2 it yields 1792 + // bytes; at channels=1, 203648 for VOICE_D_453. The decoded SAMPLE COUNT is + // independent of the declared rate (the rate only sets playback speed), so + // the subtitle cue can be used to solve for the real rate instead. + let bound = [ + ("hokyu_LS_s02A", 450u32), + ("hokyu_LS_s09A", 451), + ("hokyu_DS_s13A", 452), + ("hokyu_LS_s02H", 453), + ("hokyu_DS_s07H", 454), + ]; + println!( + "{:<16} {:>6} {:>9} {:>9} {:>10} {:>9} {:>12}", + "movie", "bank", "stereo B", "mono B", "samples", "cue s", "implied Hz" + ); + for (movie, n) in bound { + let path = format!("eng\\etc\\VOICE_D_{n}.slb"); + let Some(entry) = snd.find_by_name(&path) else { continue }; + let bytes = snd.read(entry).expect("read"); + let Some(first_riff) = bytes.windows(4).position(|w| w == b"RIFF") else { continue }; + if first_riff <= slb::HEADERLESS_DATA_OFFSET { + println!("{movie:<16} {:>6} (no leading region)", format!("D_{n}")); + continue; + } + let lead = &bytes[slb::HEADERLESS_DATA_OFFSET..first_riff]; + let stereo = decode_bytes(&riff(&xma1_fmt(2, 2, 48000), lead)); + let mono = decode_bytes(&riff(&xma1_fmt(1, 0, 48000), lead)); + let samples = mono / 2; // 16-bit mono + let cue = movie_subtitle::track_voice_cues(&lang, movie) + .iter() + .map(|(_, t)| *t) + .fold(0.0f32, f32::max); + let implied = if cue > 0.0 { samples as f32 / cue } else { f32::NAN }; + println!( + "{movie:<16} {:>6} {stereo:>9} {mono:>9} {samples:>10} {cue:>9.2} {implied:>12.0}", + format!("D_{n}") + ); + } + println!("\n'implied Hz' = decoded samples / the movie's last subtitle cue."); + println!("A consistent value near a standard rate is the real sample rate."); +} diff --git a/docs/re/BACKLOG.md b/docs/re/BACKLOG.md index 07e9763b..9926b81d 100644 --- a/docs/re/BACKLOG.md +++ b/docs/re/BACKLOG.md @@ -1063,12 +1063,21 @@ premise was wrong.** five banks fail that: `D_450` cue 4.00 s vs 1.41 s decoded, `D_451` 3.70 vs 1.81, `D_453` **4.70 vs 0.07**. The other two have their only cue at 0.0 s and give no signal. Artifact `examples/voice_len_vs_subs.rs`, FFmpeg-measured. - ❔ **The fmt-variation probe was INCONCLUSIVE** — 36 combinations over - `VOICE_D_453`'s leading region all produced 0 PCM bytes, *including* ones - equivalent to the crate's working `synth_xma1_fmt`, so the probe tested my - hand-built `fmt` chunk rather than the hypothesis. Not evidence the region is - non-XMA. ▶️ Retry building the chunk with the crate's own helper and varying - its parameters. See + ✅ **(same day) The leading region IS XMA1 — MONO, not stereo.** At + `channels = 2` every bank decodes to *exactly* 1792 bytes regardless of size + (one frame, then it stops); at `channels = 1` the same data yields up to + **113× more** — `VOICE_D_453` goes 1 792 → **203 648**. The bank's own RIFF + sub-wave is decoded through the same pipe as a control (13 568 bytes), so the + harness is sound, and the all-zero `VOICE_D_451` region is the control the + other way. The earlier 0-byte probe was my own error: I read + `synth_xma1_fmt`'s second argument as a stream count when it is a **channel + mask**. ❌ Solving for the sample rate from the subtitle cue **does not + converge** — 21 665 Hz for `D_453` (temptingly near 22 050, and I nearly wrote + it down) but **5 844 Hz** for `D_450`. The decodes are partial: samples per + input byte ranges 2.10–4.96 where a clean decode would be near-constant. + ▶️ Next: find why FFmpeg stops early — likely the packet/subframe fields in the + synthesised `fmt`, which are hardcoded (`SubframeData = 4`, `NumStreams = 1`). + See [`voice-bank-leading-region.md`](voice-bank-leading-region.md). * ❌ **(2026-08-25) My own boot-nav diagnosis, MEASURED AND WITHDRAWN.** I said the run died because `skip_intro.sh` gates the title test at `rmse <= 1500` diff --git a/docs/re/voice-bank-leading-region.md b/docs/re/voice-bank-leading-region.md index 66efe2f5..7047123d 100644 --- a/docs/re/voice-bank-leading-region.md +++ b/docs/re/voice-bank-leading-region.md @@ -144,7 +144,54 @@ Three of the five are decisive; the other two have their only cue at 0.0 s, whic tells us nothing in either direction. So **something is genuinely missing from these banks** — independent of anything above, and now measured rather than felt. -## ❔ Varying the XMA format — inconclusive, and for a boring reason +## ✅ The leading region IS XMA1 — but **mono**, not stereo + +Retried with the `fmt ` chunk built to `slb::synth_xma1_fmt`'s exact byte layout +(and with the bank's own RIFF sub-wave decoded through the same pipe as a +**control**, so a broken harness cannot masquerade as a result). Artifact +`examples/slb_fmt_probe.rs`. + +The parameter that matters is the channel count, and the difference is not +subtle: + +| bank | leading bytes | decoded at **channels = 2** | decoded at **channels = 1** | +|---|---|---|---| +| `VOICE_D_450` | 16 384 | 1 792 | **46 756** | +| `VOICE_D_451` | 2 048 | 1 792 | 896 | +| `VOICE_D_452` | 14 336 | 1 792 | **30 154** | +| `VOICE_D_453` | 45 056 | 1 792 | **203 648** | +| `VOICE_D_454` | 59 392 | 1 792 | **294 440** | + +`channels = 2` yields **exactly 1792 bytes for every bank regardless of size** — +one frame, then it stops. That constant is the tell: stereo is simply the wrong +shape. At `channels = 1` the same data yields up to **113× more** audio, and the +control sub-wave decodes to 13 568 bytes, so the harness is sound. + +`VOICE_D_451` decoding to almost nothing is the expected control the other way: +its leading region is the all-zero one. + +The sample rate and channel mask make **no difference to the decoded byte +count** — as expected, since they set playback speed rather than sample count. + +## ❌ Solving for the sample rate from the subtitle cue — does NOT converge + +`decoded samples / last subtitle cue` should give the real rate if the decode +were complete. It does not agree with itself: + +| bank | samples | cue | implied | +|---|---|---|---| +| `VOICE_D_453` | 101 824 | 4.70 s | 21 665 Hz | +| `VOICE_D_450` | 23 378 | 4.00 s | **5 844 Hz** | + +21 665 Hz is temptingly close to 22 050, and I nearly wrote that down. The second +bank refutes it: no single rate explains both. The decodes are also visibly +**partial** — decoded samples per input byte ranges 2.10 to 4.96 across the +banks, where a clean decode would be near-constant — so FFmpeg is not consuming +these streams to the end. + +So: the container is identified, the duration is not. + +## ❔ The first attempt at this — inconclusive, and for a boring reason The recorded next step was to vary the synthesised `fmt` (channels, streams, sample rate) rather than assume the container. I tried 36 combinations over @@ -152,10 +199,11 @@ sample rate) rather than assume the container. I tried 36 combinations over including combinations that should be equivalent to the crate's own `synth_xma1_fmt(2, 2, 48000)`, which does at least parse (it yields 1792 bytes). -That means the probe tested **my hand-built `fmt` chunk**, not the hypothesis. It -is not evidence that the region is non-XMA. The next attempt should build the -chunk with the crate's own helper and vary its parameters, rather than -hand-rolling the WAVEFORMATEX. +That means the probe tested **my hand-built `fmt` chunk**, not the hypothesis. +The specific error is worth recording: I read `synth_xma1_fmt(2, 2, 48000)`'s +second argument as a *stream count* when it is a **channel mask**, and built the +`WAVEFORMATEX` around that misreading. Replicating the real layout is what turned +0 bytes into 203 648. ## What this does not settle