port: fold only the channels that carry signal, and measure what the leading chunk actually is

TWO DEFECTS AND ONE MEASUREMENT, all from verifying the previous commit rather
than from reading it.

Channel 2 of both voice streams is DIGITALLY SILENT -- peak -inf over the whole
file. The voice is a mono recording carried in a nominally stereo stream, and
averaging it with silence cost 5.94 dB. The doc comment directly above the code
that did it warned that "a stereo matrix applied to a mono voice track is not an
error, it is a -6 dB attenuation that nothing reports", and then the code checked
the DECLARED channel count instead of the content. `live_channels` now measures
which channels carry signal and averages only those.

Three defects this iteration were 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 contributing nothing while still counting in a divisor, and none
is visible in anything but a level.

THE LEADING CHUNK IS THE TAIL OF THE FULL-LENGTH ONE. The Decoder settled by
byte-span analysis that it is the movie's own dialogue, 17 of 17 -- killing its
own hypothesis that it was an in-mission line -- and asked whether dropping it is
a truncation, having no XMA1 decoder. Sliding envelope correlation with overhang
allowed and normalised over the overlap: ADV r=0.998 at +52.8 s, S00A r=0.932 at
+25.6 s, against controls of 1.000 (self) and 0.289 (a different movie). Both
lags put chunk 0 flush against the END of chunk 1. Sample domain, lag refined to
one sample then a scalar best-fit: residuals 16.70 dB and 23.15 dB below target.

So dropping it removes a DUPLICATE and the exporter was right for a worse reason
than it gave. The manifest note is NOT rewritten to claim that -- the structural
conclusion belongs on the Decoder's page, not in my manifest -- but it no longer
equates the chunk with BGM_103's third sub-wave, which a disc-wide census showed
is a different structure, and it now says in words that the omission must not be
read as junk removal.

Not converted, deliberately: the Decoder's 504464 B anchor constant. Bytes per
second is not constant even inside one region -- chunk 1 is 1118268 B and chunk 2
is 1171516 B for the SAME 137.324 s -- so any figure in seconds off it would be
invented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
This commit is contained in:
Sylpheed port agent
2026-08-29 15:07:30 +00:00
parent cb8d77febc
commit 4817e5ea9e
2 changed files with 182 additions and 24 deletions

View File

@@ -485,16 +485,24 @@ pub fn export_bgm<S: DiscSource + ?Sized>(
/// 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<S: DiscSource + ?Sized>(
}
let staged: Vec<PathBuf> = 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<String> = (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<String> = 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<S: DiscSource + ?Sized>(
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<S: DiscSource + ?Sized>(
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<usize> {
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<usize> = 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::<usize>().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::<f32>().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
}

View File

@@ -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**. 9899.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.