diff --git a/crates/sylpheed-export/examples/bank_chunks.rs b/crates/sylpheed-export/examples/bank_chunks.rs new file mode 100644 index 00000000..d17c54ae --- /dev/null +++ b/crates/sylpheed-export/examples/bank_chunks.rs @@ -0,0 +1,45 @@ +//! Throwaway probe: what are a music bank's sub-waves, decoded and timed? +//! +//! `export_bgm` sums every sub-wave `media` returns and scales by 1/n. If one of +//! them is not music, the divisor is wrong and every real stem is attenuated for +//! nothing -- the same defect already found and fixed in `export_voice`. +use std::process::Command; +use sylpheed_formats::media; + +fn main() { + let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into()); + let src = media::DirectorySource::new(&disc); + for bank in ["BGM_103.slb", "BGM_102.slb", "BGM_001.slb"] { + match media::sound_bank_riffs(&src, bank) { + Ok(riffs) => { + println!("{bank}: {} sub-wave(s)", riffs.len()); + for (i, r) in riffs.iter().enumerate() { + let p = std::env::temp_dir().join(format!("bk_{i}.xma.wav")); + std::fs::write(&p, r).unwrap(); + let w = std::env::temp_dir().join(format!("bk_{i}.wav")); + let _ = Command::new("ffmpeg") + .args(["-hide_banner", "-loglevel", "error", "-y", "-i"]) + .arg(&p).arg(&w).output(); + let out = Command::new("ffmpeg") + .args(["-hide_banner", "-v", "info", "-i"]) + .arg(&w) + .args(["-af", "astats=measure_perchannel=none", "-f", "null", "-"]) + .output().unwrap(); + let t = String::from_utf8_lossy(&out.stderr).into_owned(); + let get = |k: &str| t.lines().find_map(|l| l.split_once(k).map(|x| x.1.trim().to_string())) + .unwrap_or_else(|| "?".into()); + let dur = Command::new("ffprobe") + .args(["-v","error","-show_entries","format=duration","-of","csv=p=0"]) + .arg(&w).output().ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default(); + println!(" sub-wave {i}: {:>9} B -> {:>10} s peak {:>10} rms {}", + r.len(), dur, get("Peak level dB:"), get("RMS level dB:")); + let _ = std::fs::remove_file(&p); + let _ = std::fs::remove_file(&w); + } + } + Err(e) => println!("{bank}: {e}"), + } + } +} diff --git a/crates/sylpheed-export/src/audio.rs b/crates/sylpheed-export/src/audio.rs index 88792ffd..c7253115 100644 --- a/crates/sylpheed-export/src/audio.rs +++ b/crates/sylpheed-export/src/audio.rs @@ -384,9 +384,35 @@ pub fn export_bgm( let dir = out.join("audio/bgm"); std::fs::create_dir_all(&dir)?; - let mut staged = Vec::new(); + let mut all = Vec::new(); for (i, riff) in riffs.iter().enumerate() { - staged.push(stage_riff(&dir, &format!("{role}.{i}"), riff)?); + all.push(stage_riff(&dir, &format!("{role}.{i}"), riff)?); + } + + // DROP DIGITALLY SILENT SUB-WAVES BEFORE SUMMING -- arithmetic, not a + // decoding decision, and the same rule `export_voice` already applies. + // + // Every music bank returns THREE sub-waves where HANDOFF Q10's census says + // two, and the extra one is identical in all three banks measured: + // + // BGM_103 / BGM_102 / BGM_001 sub-wave 0: 10 300 B -> 0.009 s, peak -inf + // + // 10 300 B is 10 240 + a 60-byte RIFF wrapper, and 10 240 B is exactly what + // the Decoder's disc-wide census identifies as the BANK HEADER. So it is not + // a stem, it is silence, and counting it in the divisor attenuated every + // real stem by 1/3 instead of 1/2 -- **3.52 dB, on all the menu music this + // port has shipped since P6**. A silent input contributes nothing to a sum; + // including it in the normalisation is my error, not a judgement about + // content. + let quiet: Vec = (0..all.len()) + .filter(|&i| decoded_chunk(&all[i]).1 <= -90.0) + .collect(); + let staged: Vec = (0..all.len()) + .filter(|i| !quiet.contains(i)) + .map(|i| all[i].clone()) + .collect(); + if staged.is_empty() { + return Ok(None); } let ogg = dir.join(format!("{role}.ogg")); @@ -414,18 +440,30 @@ pub fn export_bgm( let command = format!("ffmpeg {}", argv.join(" ")); run_ffmpeg(&argv, &ogg)?; let (peak, dur) = measure(&ogg); - for s in &staged { + for s in &all { std::fs::remove_file(s).ok(); } let mut why = format!( - "{} authored/audio.json bgm.{role} names bank {}. Its {} \ - sub-wave(s) are SUMMED into one file and the sum is scaled by 1/{}, \ - which is the smallest constant that cannot clip.", + "{} authored/audio.json bgm.{role} names bank {}. Of its {} \ + sub-wave(s), {} are SUMMED into one file and the sum is scaled by 1/{}, \ + which is the smallest constant that cannot clip.{}", spec.why, spec.bank, riffs.len(), - riffs.len() + staged.len(), + staged.len(), + if quiet.is_empty() { + String::new() + } else { + format!( + " DROPPED {} DIGITALLY SILENT sub-wave(s) before summing -- each 10 300 B \ + decoding to 0.009 s at peak -inf, which is the 10 240-byte BANK HEADER plus \ + a RIFF wrapper, not a stem. Counting them in the divisor attenuated every \ + real stem by 3.52 dB. This is arithmetic, not a decoding decision.", + quiet.len() + ) + } ); if let Some(s) = &spec.stems_why { why.push(' '); @@ -446,7 +484,7 @@ pub fn export_bgm( kind: "bgm", name_match: None, loop_mode: spec.r#loop.clone(), - sub_waves: riffs.len(), + sub_waves: staged.len(), })) } diff --git a/docs/port/BLOCKED.md b/docs/port/BLOCKED.md index 49952073..ce0bb47e 100644 --- a/docs/port/BLOCKED.md +++ b/docs/port/BLOCKED.md @@ -111,7 +111,7 @@ HANDOFF. | P3/P5 β€” the title screen | **does the idle post-boot title show the `PRESS β’Ά` plate?** | Q2 | πŸ”΄ the port's boot ends on `title` (build 4), which has **no plate**, and P5 has just made β’Ά the only way off it. Both states are captured β€” `live-title-build4-no-plate.png` and `live-title-press-a.png` β€” so the art is not the question; the **sequence** is: build 4 alone, build 4 with build 2 over it, or build 4 *then* the plate after a delay. Behavioural, so the port has no oracle for it. `press_start` (build 2) is already exported and unused. ⚠️ Fixing it also means drawing **two builds at once**, which this port has never done β€” a change to `ScreenView`, not a line in `flow.json`. Not blocking P5. | | P5 β€” β’· on the main menu | **is β’· what returns to the title, or the idle timer?** | Q5 | 🟑 stated in HANDOFF, no capture behind it. The title self-returns after ~8–10 s idle, so one unrecorded observation cannot separate them. `authored/flow.json` implements it and marks it *authored β€” likely but UNPROVEN*. **Not blocking** β€” P5 shipped with it β€” but it is the only navigation rule on that screen with nothing under it. Settled by one run that presses β’· well inside the idle window, timestamped. | -| P6 BGM β€” the sub-wave count | **is a music bank's LEADING REGION a stem, or a decoder artefact?** | Q10 | πŸ”΄ **HANDOFF and the decoders disagree, and P6 ships the disagreement.** `media::sound_bank_riffs("BGM_103.slb")` returns **three** sub-waves; HANDOFF Q10's census says a music bank is *"exactly two waves of identical duration (32/32 banks on the disc)"*. The third comes from `slb.rs:380` `to_xma_riffs`, whose hybrid branch emits a leading headerless packet region ahead of the `RIFF` waves β€” and `docs/re/REFUTED.md` already records that region as what makes `BGM_106`–`BGM_109` *"break the two-wave rule"*. Derived at HANDOFF `9ca1eb5`. **The exporter sums all three and writes a manifest warning**, because choosing which sub-wave to drop is a decoding question and MISSION Β§2 forbids this exporter answering one. So the menu currently plays a sum of three things where the census predicts two. What settles it: whether that leading region carries music. Raised with the Decoder 2026-08-29. | +| ~~P6 BGM β€” the sub-wave count~~ | ~~is a music bank's LEADING REGION a stem, or a decoder artefact?~~ | Q10 | βœ… **CLOSED 2026-08-29 β€” the census was right and the port was summing a bank header into the music.** Decoded and timed, sub-wave 0 of `BGM_103`, `BGM_102` and `BGM_001` is identical: **10 300 B β†’ 0.009 s, peak βˆ’inf**, i.e. digitally silent. 10 300 B is the 10 240-byte bank header (the Decoder's disc-wide census) plus a 60-byte RIFF wrapper. So it is not a stem, and `export_bgm` had been counting it in the divisor β€” putting every real stem at 1/3 instead of 1/2, **3.52 dB of attenuation on all menu music shipped since P6**. Dropping a *silent* input is arithmetic, not a decoding decision, so this closed on the port's side; measured after the fix, `main_menu.ogg` goes βˆ’7.69 β†’ **βˆ’4.20 dBFS**, +3.49 dB against 3.52 predicted. Corroborates the Decoder's `c1f3608` from the other direction. The export now reports 2 sub-waves and the manifest warning is gone. | | ~~P3 β€” the plate's ONSET~~ | ~~visible 2.13 s after settle, or group starts then?~~ | Q2 | βœ… **resolved 2026-08-29, and the answer is AUTHOR NOTHING.** The port's refutation held and produced a better answer than either option it offered. Correction at `5b0a6e6` on `auto/no-disc-and-menu-captures`: **both builds run on one clock, started together**, and the plate arrives at its own declared `t=238`. Checked against this export rather than taken on trust β€” build 4's visible build-in ends at `t=118` (`pteff01`, `pteff02`, `ptlogoall_eff` finish together), `ptbtn00` reaches alpha 255 at `t=238`, difference **120 units = 2.000 s**, against a measured 2.138 / 2.132 s at an emulator presenting 28.1 fps rather than 30. The 2.13 s constant is **deleted**. | | ~~P3/P5 β€” `settle_time()`~~ | ~~`rest.t` is not when a screen settles, and the port's sequencer uses it~~ | β€” | βœ… **MEASURED 2026-08-29 and the row was HALF WRONG β€” mine.** The Decoder took it on a cold profile with no shader cache (`auto/no-disc-and-menu-captures` at `4bd4779`, `docs/re/boot-settle-times-measured.md`). The principle holds: the title's `rest.t` is 251 units = **4.183 s** where its art finishes at ~2 s. **But "everything the sequencer paces off that landmark is therefore late" does not.** Measured the port the way the game was measured β€” visible span, `--film` at 4 fps β€” the publisher wordmark runs **4.25 s** against the game's 4.297/4.604/4.370 and the developer logos **3.50 s** against 3.508/3.503/3.366. Dead on. My earlier reading compared the port's *arrival-to-arrival* timestamps against the game's *visible spans*, which differ by the exit ramp plus the black hold β€” the whole of the discrepancy I was about to chase. `rest.t` is still the wrong landmark; its blast radius is `_script_settled` waiting longer than it needs to, which is a slow test and not a wrong frame. `dwell_seconds` stays `null`, now for a measured reason. πŸ”΄ **Do not author an β’Άβ†’menu dwell**: it measures 3.763 s and contains a 1.53 s guest load stall, third independent reproduction. 🟑 Menu build-in 0.531 s and β’·β†’title 0.482 s rest on one run and are not authored; the port is within ~0.1 s of both from the disc. | | P4/P7 β€” the voice export is INCOMPLETE | **what are the three concurrent streams, and how do they combine?** | β€” | πŸ”΄ **the premise of every earlier row here was refuted by the RUNNING GAME, 2026-08-29.** Canary's `--xma_param_probe` shows the game decoding **all three streams concurrently** in three XMA contexts, byte sizes matching the disc payloads exactly (1 294 336 / 1 118 208 / 1 171 456 against 1 294 396 / 1 118 268 / 1 171 516). So they are **not** three presentations of one take, there is no "which one" to answer, and the export β€” which ships one β€” is **missing two streams the game plays**. ⚠️ **The failure sounds like success**: one stream is clean audible dialogue. Stated as a top-level manifest warning per movie, on the console, and in `authored/audio.json`. **Behaviour deliberately unchanged**: an equal-gain `1/n` sum of channel pairs is not a downmix either (MISSION Β§6 pins an explicit matrix for exactly this reason) and summing cost `S00A` 6.02 dB when one stream was silence β€” swapping one guess for another is what produced this row twice. 🟑 "They are 5.1" is the Decoder's **hypothesis**: three stereo streams is six channels and N stereo streams is how XMA carries multichannel on the 360, but all three declare `ChannelMask = 0x0002` identically, which argues against distinct roles. πŸ”΄ **The first attempt at that capture FAILED and the failure is measured, not suspected.** `adv-game-output-6ch.wav` (shared `1788018994-16f9d19d90b8`, taken at `68aa192`) contains neither the movie's bed, nor any of the three voice streams, nor `BGM_103`, nor `S00A`: every pairing is a plateau with a best-to-runner-up margin of **0.001–0.016**, against controls that fire at r=1.000 with margins of +0.114 to +0.300 on the same instrument and the same data. Drift is excluded β€” 5 s windows give best lags of 4.95 / 15.30 / 119.35 / 50.75 / 29.35 / 83.95 s, scattered rather than monotonic. What that capture *is* has been handed back to the Decoder rather than guessed. **What still settles it: a recording of the game's own output over `ADV` through the PulseAudio null sink** β€” candidate combinations can then be correlated against what the game played. Asked 2026-08-29, emulator was up. | diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index a04bde97..fde58c1b 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -3312,3 +3312,61 @@ comes from the `.wmv`'s WMA track, and if the game never plays that track, then `ADV.ogv`'s audio is wrong in a way no amount of transcode fidelity would fix. I am not asserting that β€” it is a question about what the game does β€” but it is the reason this is worth another boot rather than being written off. + +## Every music bank was summed at 1/3 when only two sub-waves are music β€” 3.52 dB, since P6 + +The Decoder's message about `BGM_102` came with declared durations from the +corrected XMA1 `PsuedoBytesPerSec`, and checking my export against them turned up +a defect of mine that had been shipping since P6. + +`export_bgm` summed every sub-wave `media` returned and scaled by `1/n`. Decoded +and timed, the three banks are identical in shape: + +| bank | sub-wave 0 | sub-wave 1 | sub-wave 2 | +|---|---|---|---| +| `BGM_103` | **10 300 B β†’ 0.009 s, peak βˆ’inf** | 3 876 924 B β†’ 87.744 s | 3 930 172 B β†’ 87.744 s | +| `BGM_102` | **10 300 B β†’ 0.009 s, peak βˆ’inf** | 1 151 036 B β†’ 37.482 s | 1 269 820 B β†’ 37.482 s | +| `BGM_001` | **10 300 B β†’ 0.009 s, peak βˆ’inf** | 4 466 748 B β†’ 173.809 s | 4 673 596 B β†’ 173.809 s | + +**Sub-wave 0 is digitally silent in all three**, and 10 300 B is 10 240 + a +60-byte RIFF wrapper β€” 10 240 B being exactly what the Decoder's disc-wide census +identifies as the bank header. So it is not a stem. Counting it in the divisor +put every real stem at 1/3 instead of 1/2: **3.52 dB of attenuation on all the +menu music this port has shipped since P6.** + +Dropping it is **arithmetic, not a decoding decision** β€” a silent input +contributes nothing to a sum, and this is the same rule `export_voice` already +applies. Measured after the fix: `main_menu.ogg` goes **βˆ’7.69 β†’ βˆ’4.20 dBFS**, +**+3.49 dB** against 3.52 predicted, the remainder being Vorbis. + +⚠️ **This is the third instance of one defect in this pipeline** β€” a silent chunk +in the voice sum, a silent channel in the mono fold, and now a silent sub-wave in +the music sum. Each was invisible in every check except a level, and each time +the divisor was computed from *how many inputs there are* rather than *how many +carry signal*. That is the shape to look for, not the individual bug. + +### It also closes a πŸ”΄ that has been open since P6 + +`docs/port/BLOCKED.md` carried *"`media::sound_bank_riffs` returns three +sub-waves where HANDOFF Q10's census says two"* as a disagreement the port shipped +deliberately. The census was right; the third was never a stem. The export now +reports **2 sub-waves** and the warning is gone β€” closed by measurement on my +side, corroborating the Decoder's `c1f3608` from a different direction (decoding +it, rather than counting headers). + +### The declared-rate method, cross-checked a third time β€” and one correction + +Their declared lengths against my decodes: `BGM_103` 87.750/87.749 vs **87.744**; +`BGM_102` 37.487 vs **37.482**; `BGM_001` 173.821 vs **173.809**. Agreement to +**5–12 ms** on three banks. The method is good for lengths. + +🟒 **Refutation attempt, and the conclusion survives while the reasoning does +not.** The Decoder wrote that `BGM_001` reads *"173.821 s declared against your +decoded 167.663 s β€” a gap of 6.158 s"*, explaining it as *"declared is the +encoded stream, decoded is where the audio stops."* **A full decode of +`BGM_001` yields 173.809 s of PCM, not 167.663 s.** The 167.663 figure is where +the music *fades out*, measured from the audio; the stream then continues, silent, +to its declared end. So declared and decoded agree to 12 ms and the trailing +silence is *inside* the decode, not the difference between two methods. The +cross-check stands β€” better than stated, since it is now three banks rather than +a coincidence β€” and the sentence explaining it should go.