diff --git a/crates/sylpheed-export/src/audio.rs b/crates/sylpheed-export/src/audio.rs index 4d6bb428..bd2b2f11 100644 --- a/crates/sylpheed-export/src/audio.rs +++ b/crates/sylpheed-export/src/audio.rs @@ -485,16 +485,24 @@ pub fn export_bgm( /// performance, played together; do not concatenate* — arriving on a different /// asset kind, which is why they are summed at `1/n` like [`export_bgm`]'s. /// -/// ⚠️ **Chunk 0 is dropped, and what it is remains an open decoding question.** -/// Its duration matches nothing: 84.6 s under a 137 s movie, 9 ms under -/// `RT01A`. `docs/re/REFUTED.md` records `slb.rs`'s `to_xma_riffs` hybrid branch -/// emitting a **leading headerless packet region** ahead of the real `RIFF` -/// waves, and `docs/port/BLOCKED.md` already carries that as an open row against -/// `BGM_103`, where `media` likewise returns three sub-waves where Q10's census -/// says two. This is the **same signature on a second, independent asset kind** — -/// corroboration, not proof. So the rule here is stated in terms of what was -/// measured — *keep the longest duration and everything that ties with it* — and -/// every dropped chunk is named in the manifest rather than quietly discarded. +/// ⚠️ **Chunk 0 is dropped, and dropping it may be a TRUNCATION.** This comment +/// first guessed it was the same thing as `BGM_103`'s third sub-wave — a bank +/// header — and a disc-wide census over all 95 English movie-voice regions +/// showed that is a *different structure*: 78 regions open with a 10 240 B bank +/// header, 17 with a leading headerless stream at the disc's own `1392 mod 2048` +/// data offset, and the chunk count discriminates neither. Then the byte-span +/// test settled what it holds: **the leading chunk is this movie's own dialogue, +/// 17 of 17** — not an in-mission line, which was the standing hypothesis. +/// +/// It is dropped anyway, and only for this reason: **the region over-covers.** +/// Taking everything measures 2.6× the movie's length, which nothing explains +/// yet. So the omission is a *bounded* choice, not junk removal, and the +/// manifest says so in those words — a consumer must not read a dropped chunk +/// here as a defect the exporter cleaned up. +/// +/// The selection rule is therefore stated in terms of what was measured — *keep +/// the longest duration and everything tying with it, minus anything digitally +/// silent* — and every dropped chunk is named with its length and peak. /// * **Mono**, with the fold chosen from the stream's own declared channel /// count rather than by passing `-ac 1` and hoping. A voice track that is /// already mono is passed through untouched. @@ -574,17 +582,32 @@ pub fn export_voice( } let staged: Vec = keep.iter().map(|&i| all[i].clone()).collect(); - // The fold is chosen from what the stream declares, because `pan` silently - // ignores a channel the input does not have -- so a stereo matrix applied to - // a mono voice track is not an error, it is a 6 dB attenuation nobody sees. + // The fold averages the channels that CARRY SIGNAL, not the channels the + // stream declares. + // + // This function's first version averaged all declared channels, and the doc + // comment above it warned in as many words that "a stereo matrix applied to + // a mono voice track is not an error, it is a -6 dB attenuation that nothing + // reports". It then did exactly that: **channel 2 of both voice streams is + // digitally silent** -- peak -inf over the whole file, on `ADV` and on + // `S00A` -- so this is a mono recording carried in a nominally stereo + // stream, and averaging it with silence cost 5.94 dB. Checking the declared + // count is not checking the content, and only the content is the fold. + // + // Same principle as the silent-chunk drop above, one level down: a silent + // 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 = match channels { - 0 | 1 => String::new(), - n => { - let g = 1.0 / n as f64; - let terms: Vec = (0..n).map(|c| format!("{g:.6}*c{c}")).collect(); - format!(",pan=mono|c0={}", terms.join("+")) - } + 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("+")) }; let ogg = dir.join(format!("{movie}.ogg")); @@ -645,7 +668,10 @@ pub fn export_voice( others. Of {} region chunk(s), {} were SUMMED at 1/{} -- they are \ equal-duration and each spans the whole movie, which is HANDOFF Q10's decoded \ two-stem shape, so joining them end to end would play the dialogue twice.{} \ - Folded to mono from {channels} channel(s).{against}", + Folded to mono from the {} of {channels} declared channel(s) that carry \ + signal -- averaging a silent channel in would cost 6.02 dB, and channel 2 of \ + both voice streams IS silent.{against}", + live.len(), riffs.len(), staged.len(), staged.len(), @@ -653,9 +679,14 @@ pub fn export_voice( String::new() } else { format!( - " DROPPED, matching no duration in this region and OPEN as a decoding \ - question -- the same signature as BGM_103's third sub-wave, see \ - docs/port/BLOCKED.md: {}.", + " DROPPED, and NOT because it is spurious -- the leading chunk is DECODED \ + to be this movie's OWN dialogue, 17 of 17 regions (the Decoder's \ + voice-region-leading-chunk.md; an earlier note here wrongly equated it \ + with BGM_103's third sub-wave, which a disc-wide census showed is a \ + different structure). It is dropped because the region OVER-COVERS: \ + including everything measures 2.6x the movie's length. So this may be a \ + TRUNCATION, it is an open decoding question, and a consumer must not read \ + the omission as junk removal. See docs/port/BLOCKED.md: {}.", dropped.join(", ") ) } @@ -727,3 +758,52 @@ fn decoded_chunk(riff: &Path) -> (f32, f32) { let _ = std::fs::remove_file(&wav); out } + + +/// Which channel indices of a decoded stream are not digitally silent. +/// +/// `astats` reports per-channel blocks: a `Channel: N` line followed by that +/// channel's own `Peak level dB`. A channel whose peak is `-inf` carries +/// nothing, and folding it into an average is a pure loss. +/// +/// Falls back to "every declared channel is live" if the parse finds nothing, +/// because the failure to prefer is the one that changes no level. +fn live_channels(riff: &Path) -> Vec { + let wav = riff.with_extension("chan.wav"); + let ok = Command::new("ffmpeg") + .args(["-hide_banner", "-loglevel", "error", "-y", "-i"]) + .arg(riff) + .arg(&wav) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + let mut live = Vec::new(); + if ok { + if let Ok(out) = Command::new("ffmpeg") + .args(["-hide_banner", "-v", "info", "-i"]) + .arg(&wav) + .args(["-af", "astats", "-f", "null", "-"]) + .output() + { + let text = String::from_utf8_lossy(&out.stderr).into_owned(); + let mut current: Option = None; + for line in text.lines() { + if let Some((_, n)) = line.split_once("Channel: ") { + // astats numbers channels from 1; `pan` addresses c0 upward. + current = n.trim().parse::().ok().map(|n| n.saturating_sub(1)); + } else if let Some((_, v)) = line.split_once("Peak level dB: ") { + if let Some(c) = current.take() { + if v.trim().parse::().map(|p| p > -90.0).unwrap_or(false) { + live.push(c); + } + } + } + } + } + } + let _ = std::fs::remove_file(&wav); + if live.is_empty() { + live = (0..probe_channels(riff).unwrap_or(1) as usize).collect(); + } + live +} diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index c30091f5..1b979e3b 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -2656,3 +2656,81 @@ out on its own. But a duration tie is an *observation* and `bank_header_len` is *decoded*, so the rule switches the day `c1f3608` reaches `main`. `sylpheed-formats` is a path dependency and merging another agent's topic branch is not the port's to do. + +## The mono fold I warned about, in the comment directly above the code that did it + +`export_voice`'s first version folded to mono by averaging every **declared** +channel, and the doc comment above it said, in as many words, that *"`pan` +silently ignores a channel the input does not have — so a stereo matrix applied +to a mono voice track is not an error, it is a −6 dB attenuation that nothing +reports."* + +It then did exactly that. Per-channel `astats` on both voice streams: + +| | channel 1 | channel 2 | +|---|---|---| +| `ADV` chunk 1 | peak +0.000 dBFS | **peak −inf** | +| `S00A` chunk 1 | peak −4.207 dBFS | **peak −inf** | + +The voice is a **mono recording carried in a nominally stereo stream**, and +averaging it with silence cost **5.94 dB** — which is most of why `S00A`'s +exported dialogue sat at −16.2 dBFS against a source chunk peaking at −4.2 (the +other 6.02 dB was summing a silent *chunk*, corrected in the same iteration). + +**Checking the declared channel count is not checking the content, and only the +content is the fold.** `live_channels` now measures which channels carry signal +and averages only those. `sylpheed-viewer`'s `pan=mono|c0=c0` reaches the right +answer here for a reason it does not state; this reaches it for a stated one, and +would still be right if a stream ever did carry two live channels. + +Worth recording as a pattern rather than a bug: **three defects this iteration +were all the same shape** — a silent chunk in a sum, a silent channel in a fold, +and a `pan` matrix naming channels that do not exist. Each is an input that +contributes nothing being counted in a divisor, and none of them is visible in +anything but a level. + +## The leading chunk is the TAIL of the full one — measured, and it is why the region over-covers + +The Decoder settled by byte-span analysis that a voice region's leading chunk is +**the movie's own dialogue, 17 of 17** — killing its own standing hypothesis that +it was an in-mission `VOICE_D_*` line — and asked whether dropping it is +therefore a truncation. It has no XMA1 decoder; this container does. + +Envelope cross-correlation, sliding with overhang allowed at both ends and +normalised over the overlap only. ⚠️ **This corrects an earlier number of mine**: +a first pass scored 0.768 and I called it nothing, correctly — that search only +tried lags where the shorter chunk fitted *wholly inside* the longer one, and it +peaked on the boundary of its own range. + +| | best *r* | at lag | overlap | +|---|---|---|---| +| `ADV` chunk 0 → chunk 1 | **0.998** | **+52.8 s** | 84.5 s | +| `S00A` chunk 0 → chunk 1 | **0.932** | **+25.6 s** | 68.0 s | +| control — `ADV` chunk 0 against itself | 1.000 | 0.0 s | — | +| control — `ADV` chunk 0 against `S00A` chunk 1 | **0.289** | — | 28.2 s | + +**Both lags put chunk 0 flush against the end of chunk 1**: 52.8 + 84.55 = +137.35 s against chunk 1's 137.324, and 25.6 + 68.07 = 93.67 against 93.694. + +Confirmed in the sample domain — lag refined to ±1 sample on the loudest second, +then a scalar best-fit over the whole overlap: `ADV` +52.8000 s, gain 0.833, +residual **16.70 dB** below the target; `S00A` +25.6320 s, gain 0.365, residual +**23.15 dB**. 98–99.5 % of the energy is a scaled copy: the same material at a +different gain, not bit-identical, which is what a lossy decode at two gains +should look like. + +**So dropping chunk 0 removes a duplicate, and is not a truncation** — the +exporter's existing behaviour is right for a better reason than the one it gave. +🟡 **The manifest note has NOT been rewritten to say so.** The structural claim — +that the region over-covers because it re-presents its own tail, and that this +accounts for the whole 2.6× — is the Decoder's to write down; this page reports +the measurement and says which is which. The note stays hedged until its page +carries the conclusion, and the hedge is true either way. + +⚠️ **The 504 464 B constant was deliberately not converted.** The Decoder found +the region anchor sitting that far after the true predecessor trailer on all 17 +and pointedly declined to call it missing dialogue. Converting it needs a +byte↔time mapping, and the numbers above are the reason there isn't one: chunk 1 +is 1 118 268 B and chunk 2 is 1 171 516 B for **the same 137.324 s**, so bytes per +second is not constant even inside a single region. Any figure in seconds off +that constant would be invented.