From e2d889bf970250027a3c084d320c959400d5ba66 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Sun, 30 Aug 2026 08:28:12 +0000 Subject: [PATCH] port: voice export carries every qualifying stream; a unity sum was refused by check #4 answered: ADV.wmv carries ONE audio stream and it is WMA Pro 5.1, not XMA, so the movie's own track is the bed and the three streams are additional. Solving capture = 0.600 x movie + residual gives three residual signals at three positions, with LFE reproducing to -115.73 dBFS -- where nothing is added the decoders agree exactly, so the rest is added content. presentation: all keeps every equal-length non-silent survivor -- ADV 2 of 3, S00A 1 of 3 -- and the warning now keys on kept < present rather than on more than one existing. A unity sum was tried first and check refused it at +2.62 dBFS. The BGM stems precedent did not transfer: those are stems of one signal, these are positions in a field whose downmix weights sum to one whatever the assignment. Dividing by the count preserves the total and claims nothing about placement; ADV lands at -3.1. That is the OPPOSITE of the two divisor bugs already in this file, where a silent input sat in the divisor. Divide-by-N is not right or wrong in itself. Also carries their census correction: the ALSA permutation does not apply, the map is the identity, and the '82% silent' channel was LFE. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF --- authored/audio.json | 2 +- crates/sylpheed-export/src/audio.rs | 135 +++++++++++++++++++++------- crates/sylpheed-export/src/main.rs | 7 +- docs/port/DECISIONS.md | 56 +++++++++++- 4 files changed, 163 insertions(+), 37 deletions(-) diff --git a/authored/audio.json b/authored/audio.json index 2cd8eac9..af4f3644 100644 --- a/authored/audio.json +++ b/authored/audio.json @@ -174,7 +174,7 @@ "a worse position than a guess that is labelled. The value stays; the label is", "upgraded from suspicion to refutation." ], - "presentation": "loudest", + "presentation": "all", "presentation_why": [ "`loudest` = the full-length stream whose peak is nearest full scale.", "", diff --git a/crates/sylpheed-export/src/audio.rs b/crates/sylpheed-export/src/audio.rs index 383ab131..cab3a0da 100644 --- a/crates/sylpheed-export/src/audio.rs +++ b/crates/sylpheed-export/src/audio.rs @@ -107,7 +107,12 @@ pub struct BgmSpec { #[derive(Deserialize, Default, Clone, Copy, PartialEq)] #[serde(rename_all = "snake_case")] pub enum Presentation { - /// Peak nearest full scale. + /// Every equal-length survivor, summed at unity. **The authored value.** + /// + /// Measured from the game's own output: all three play. See `export_voice`. + All, + /// Peak nearest full scale. Kept so an older `authored/audio.json` loads, + /// and because the history of why one stream was chosen is worth reading. #[default] Loudest, /// Most bytes per second. @@ -198,6 +203,11 @@ pub struct Exported { pub kind: &'static str, /// The game's own identifier where it is a name match, never a measurement. pub name_match: Option, + /// How many of `sub_waves` this export actually carries. 1 while a voice + /// region shipped one of three; equal to `sub_waves` once all are summed. + /// Exists so a "known incomplete" warning fires on the gap and not on the + /// mere presence of more than one stream. + pub kept_waves: usize, /// What the runtime should do at the end of the file, where that was /// authored. `None` on a cue: a cue ends. pub loop_mode: Option, @@ -342,6 +352,7 @@ pub fn export_cues( name_match: cue.name_match.clone(), loop_mode: None, sub_waves: 1, + kept_waves: 1, }); } Ok(done) @@ -511,6 +522,7 @@ pub fn export_bgm( name_match: None, loop_mode: spec.r#loop.clone(), sub_waves: staged.len(), + kept_waves: 1, })) } @@ -701,14 +713,35 @@ pub fn export_voice( let tied: Vec = (0..all.len()) .filter(|&i| !silent.contains(&i) && (longest - lengths[i]).abs() < 0.001) .collect(); - let chosen = match presentation { - Presentation::HighestRate => tied.iter().copied().max_by_key(|&i| riffs[i].len()), + // 🔴 `All` KEEPS EVERY SURVIVOR, and it is the authored value since + // 2026-08-30. Keeping one was refuted from the OUTPUT side: the Decoder + // recorded the game's own 6-channel output over the intro and decomposed it + // as `capture = 0.600 x movie + residual`, where the residual is THREE + // signals at three positions -- front pair (r 0.918), rear pair (r 0.929), + // and a centre whose partner LFE is empty to -115 dB. All three play. This + // exporter was shipping one and discarding two. + // + // ⚠️ WHICH stream sits at which position is NOT determined -- their + // assignment is by position, not by content -- so the port does not attempt + // a 5.1 build and a positional downmix. It sums at unity, which is the same + // decision `stems: "sum"` records for a BGM bank and for the same stated + // reason: a unity sum is right under either reading, and a weighting would + // only be justified once the assignment is settled. + let keep: Vec = match presentation { + Presentation::All => tied.clone(), + Presentation::HighestRate => tied + .iter() + .copied() + .max_by_key(|&i| riffs[i].len()) + .into_iter() + .collect(), Presentation::Loudest => tied .iter() .copied() - .max_by(|&a, &b| probed[a].1.total_cmp(&probed[b].1)), + .max_by(|&a, &b| probed[a].1.total_cmp(&probed[b].1)) + .into_iter() + .collect(), }; - let keep: Vec = chosen.into_iter().collect(); let dropped: Vec = (0..all.len()) .filter(|i| !keep.contains(i)) .map(|i| { @@ -745,16 +778,21 @@ pub fn export_voice( // input contributes nothing to an average and counting it in the divisor is // arithmetic, not a mixing decision. `sylpheed-viewer`'s `pan=mono|c0=c0` // reaches the right answer here for a reason it does not state. - let live = live_channels(&staged[0]); - let channels = probe_channels(&staged[0]).unwrap_or(1); - let fold = if live.len() <= 1 && channels <= 1 { - String::new() - } else if live.len() == 1 { - format!(",pan=mono|c0=c{}", live[0]) - } else { - let g = 1.0 / live.len() as f64; - let terms: Vec = live.iter().map(|c| format!("{g:.6}*c{c}")).collect(); - format!(",pan=mono|c0={}", terms.join("+")) + // Each kept stream is folded to mono on ITS OWN live channels -- the fold is + // per input, because "which channels carry signal" is a property of the + // stream and not of the set. + let fold_of = |p: &PathBuf| -> String { + let live = live_channels(p); + let channels = probe_channels(p).unwrap_or(1); + if live.len() <= 1 && channels <= 1 { + String::new() + } else if live.len() == 1 { + format!(",pan=mono|c0=c{}", live[0]) + } else { + let g = 1.0 / live.len() as f64; + let terms: Vec = live.iter().map(|c| format!("{g:.6}*c{c}")).collect(); + format!(",pan=mono|c0={}", terms.join("+")) + } }; let ogg = dir.join(format!("{movie}.ogg")); @@ -768,7 +806,38 @@ pub fn export_voice( } // One input, so no mix and no normalising coefficient: the stream reaches the // Ogg at the level the disc has it, and the only filter is the mono fold. - let filter = format!("[0:a]anull{fold}[a]"); + // 🔴 `normalize=1` -- the mix DIVIDES by the input count, and that is right + // here for a reason the two earlier divisor bugs in this file are not. + // + // Those were wrong because an input contributing NOTHING was counted in the + // divisor: a digitally silent chunk summed, a silent channel averaged. Both + // attenuated a signal by counting silence as a voice. + // + // This is the opposite case. The three streams are not stems of one signal; + // they are three POSITIONS in a 5.1 field (front pair, centre, rear pair -- + // measured). A stereo downmix of that field weights them 0.4142, 0.2929 and + // 0.2929, which SUM TO ONE whatever the assignment. So the total is fixed + // even though the distribution is not, and dividing by three preserves that + // total while claiming nothing about which stream sits where. + // + // ⚠️ Unity summing was tried first and `check` refused it: `ADV` reached + // **+2.62 dBFS**, over the +1.0 bound. The bound is there precisely because + // "clipping is the other failure the BGM can produce, being a sum at unity + // gain" -- and it caught a mix that was 3x a downmix's level. + let filter = if staged.len() == 1 { + format!("[0:a]anull{}[a]", fold_of(&staged[0])) + } else { + let mut parts: Vec = Vec::new(); + for (i, p) in staged.iter().enumerate() { + parts.push(format!("[{i}:a]anull{}[m{i}]", fold_of(p))); + } + let ins: String = (0..staged.len()).map(|i| format!("[m{i}]")).collect(); + parts.push(format!( + "{ins}amix=inputs={}:normalize=1[a]", + staged.len() + )); + parts.join(";") + }; argv.push("-filter_complex".into()); argv.push(filter); argv.push("-map".into()); @@ -805,23 +874,25 @@ pub fn export_voice( movie -> token -> sound id -> byte region [{start}, {end}) of the continuous \ voice stream. NOT matched by filename: RT01A's voice lives inside \ VOICE_ADV.slb, so the name is right for this movie by luck and wrong for \ - others. 🔴 KNOWN INCOMPLETE: of {} region chunk(s) exactly ONE is exported, \ - and the RUNNING GAME DECODES ALL THREE CONCURRENTLY -- measured with Canary's \ - --xma_param_probe, three separate XMA contexts whose byte sizes match the three \ - disc payloads exactly. So this file is MISSING TWO STREAMS, and because a single \ - stream decodes to clean audible dialogue, nothing in the audio reveals that. The \ - earlier reading -- three presentations of one take, pick one -- is REFUTED; the \ - streams are believed to be channels, though 5.1 is a hypothesis and all three \ - declare ChannelMask 0x0002 identically, which argues against distinct roles. \ - Held rather than changed: an equal-gain 1/n sum of channel pairs is not a \ - downmix either (MISSION section 6 pins an explicit matrix for exactly this \ - reason), and summing cost S00A 6.02 dB when one stream was silence. Which stream \ - is kept: authored/audio.json voice.presentation = {:?}. What settles it: a \ - recording of the game's own output over the movie. See docs/port/BLOCKED.md.{} \ - Folded to mono from the {} of {channels} declared channel(s) that carry signal.\ - {against}", + others. ✅ ALL {} region chunk(s) are exported, summed. Keeping ONE was \ + REFUTED FROM THE OUTPUT SIDE 2026-08-30: a recording of the game's own \ + 6-channel output over the intro decomposes as capture = 0.600 x movie + \ + residual, and the residual is THREE signals at three positions -- front pair \ + (r 0.918), rear pair (r 0.929), and a centre whose partner LFE is empty to \ + -115 dB. The load-bearing number is LFE reproducing to -115.73 dBFS: where \ + nothing is added the two decoders agree exactly, so the other residuals are \ + ADDED CONTENT and not codec mismatch. ⚠️ WHICH stream sits at which position \ + is NOT determined -- the assignment is by position, not content -- so this is \ + a mono sum divided by the count, never a positional downmix. The three \ + downmix weights sum to one whatever the assignment, so the total is right and \ + the distribution is the only thing unclaimed. ⚠️ The movie's OWN track is WMA \ + Pro 5.1 and carries the bed; these streams are additional. {} kept under \ + presentation `{}`, folded to mono on each stream's own live channels.\ + {}{against}", riffs.len(), + staged.len(), match presentation { + Presentation::All => "all", Presentation::Loudest => "loudest", Presentation::HighestRate => "highest_rate", }, @@ -836,7 +907,6 @@ pub fn export_voice( dropped.join(", ") ) }, - live.len() ), peak_dbfs: peak, duration_s: dur, @@ -844,6 +914,7 @@ pub fn export_voice( name_match: None, loop_mode: None, sub_waves: riffs.len(), + kept_waves: staged.len(), })) } diff --git a/crates/sylpheed-export/src/main.rs b/crates/sylpheed-export/src/main.rs index 5355644b..b3f755b8 100644 --- a/crates/sylpheed-export/src/main.rs +++ b/crates/sylpheed-export/src/main.rs @@ -445,7 +445,7 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> { // export is known to be missing audio the game plays, and // the failure sounds like success: one stream decodes to // clean dialogue, so nobody listening finds out. - if a.sub_waves > 1 { + if a.kept_waves < a.sub_waves { warnings.push(format!( "{}: KNOWN INCOMPLETE. This region holds {} streams and the RUNNING \ GAME DECODES ALL OF THEM CONCURRENTLY (Canary --xma_param_probe: \ @@ -459,12 +459,13 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> { )); } println!( - " voice {:<8} -> {} ({}, 1 of {} streams{})", + " voice {:<8} -> {} ({}, {} of {} stream(s){})", a.name, a.file, describe(&a), + a.kept_waves, a.sub_waves, - if a.sub_waves > 1 { " -- KNOWN INCOMPLETE, see warnings" } else { "" } + if a.kept_waves < a.sub_waves { " -- KNOWN INCOMPLETE, see warnings" } else { "" } ); audio.push(ManifestAudio::from(a)); } diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index c35331e7..075b3c0a 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -9,7 +9,7 @@ dies, which is what this file is for. -125 sections. Search this before re-deriving anything. +126 sections. Search this before re-deriving anything. * [P0 — the exporter, 2026-08-28](#p0--the-exporter-2026-08-28) * [P1 — Godot draws the screen, 2026-08-28](#p1--godot-draws-the-screen-2026-08-28) @@ -136,6 +136,7 @@ dies, which is what this file is for. * [A static overlay now advances, and a refutation attempt on the pulse floor](#a-static-overlay-now-advances-and-a-refutation-attempt-on-the-pulse-floor) * [Their pulse floor reproduces exactly once the predicate is named — 159, to the pixel](#their-pulse-floor-reproduces-exactly-once-the-predicate-is-named--159-to-the-pixel) * [My rendered pulse, counted in their units — and #4 refutes the voice value without fixing it](#my-rendered-pulse-counted-in-their-units--and-4-refutes-the-voice-value-without-fixing-it) +* [The voice export now carries every qualifying stream — and a unity sum was refused by our own check](#the-voice-export-now-carries-every-qualifying-stream--and-a-unity-sum-was-refused-by-our-own-check) ## P0 — the exporter, 2026-08-28 @@ -7337,3 +7338,56 @@ nothing about *which* stream lands where, so it does not make summing right; and `kFrameChannelsDefault`. The evidence is that five of them *differ*, which a stereo guest cannot produce. The number of channels in a capture is a property of the capture. + +## The voice export now carries every qualifying stream — and a unity sum was refused by our own check + +#4 is answered and it reframes the question the port had been asking. **`ADV.wmv` +carries one audio stream and it is WMA Pro 5.1, not XMA** — so "which of three +voice streams to ship" was missing the bed entirely. The movie's own track is the +bed; the three streams are *additional*. + +Solving `capture = 0.600 × movie + residual` per channel, the gain is 0.600 +uniformly (−4.44 dB), and the residual is **three signals at three positions** — +front pair (r 0.918), rear pair (r 0.929), and a centre whose partner LFE is empty +to −115 dB. 🔴 The load-bearing number is **LFE reproducing to −115.73 dBFS**: +where nothing is added the two decoders agree essentially exactly, so the other +residuals are **added content**, not codec mismatch. + +`presentation: "all"` now keeps every equal-length non-silent survivor: +`ADV` **2 of 3**, `S00A` **1 of 3**. The third `ADV` chunk is the leading one this +port had already measured to be the *tail* of another (r=0.998, lag flush against +its end) — correctly dropped — and `S00A`'s others are digitally silent. The +top-level warning now keys on **kept < present** rather than on "more than one +stream exists", so it still fires and says what is absent. + +### 🔴 A unity sum was tried and `check` refused it + +First attempt summed at unity, on the precedent of `stems: "sum"` for a BGM bank. +`ADV` came out at **+2.62 dBFS**, over the +1.0 bound, and the validator rejected +the tree. + +It was right, and the precedent did not transfer. A BGM bank's two waves are +**stems of one signal**; these three are **positions in a 5.1 field**. A stereo +downmix weights them 0.4142, 0.2929 and 0.2929 — which **sum to one whatever the +assignment**. So the total is fixed even though the distribution is unknown, and +dividing by the input count preserves that total while claiming nothing about +which stream sits where. `ADV` now lands at **−3.1 dBFS**. + +⚠️ Note this is the *opposite* of the two divisor bugs this file already carries. +Those were wrong because an input contributing **nothing** sat in the divisor — a +silent chunk summed, a silent channel averaged. Here every input carries signal +and the weights genuinely sum to one. "Divide by N" is not right or wrong in +itself; it depends on whether the inputs are parts of one signal or parts of one +field, and I reached for the wrong precedent first. + +### What is still not claimed + +⚠️ **Which stream sits at which position is not determined** — their assignment is +by position, not content — so the port builds no 5.1 and applies no positional +downmix. ⚠️ Their correction to the earlier census page is carried too: the ALSA +permutation `[0,1,4,5,2,3]` does **not** apply to that capture; recomputing with no +assumed order gives the **identity**, so the "BR is 82 % silent" channel was really +**LFE**, which reconciles with the movie's own 80.64 % silent LFE. I had recorded +the census's channel labels; they are corrected here rather than left standing. + +Every asserting check passes.