port: apply the measured positional weights, keyed by byte size

ADV's three chunks map onto the Decoder's contexts exactly, each a declared
byte_size plus the 60-byte RIFF header. authored/audio.json gains
voice.stream_weights keyed by SIZE, and the exporter weights only when every kept
stream matches, falling back to the divisor otherwise.

The key is the check: two weeks ago these same sizes did not fit the region the
resolver returned, which is how the 238-packet late start was found. Applied
positionally the weights would have gone onto the wrong streams silently. S00A
matches nothing and keeps the divisor. ADV mixes at 0.4142/0.2929/0.2929, -2.87.

Unlooked-for confirmation: the filter folds chunks 0 and 2 from two live channels
and chunk 1 from one, found independently by live_channels, matching their claim
that ctx1 is the only stream with a silent channel.

Also files an UNEXPLAINED regression: verify-menu-audio's dead-press check now
reports DIFFER across three runs, diverging at 0.085 s with different durations.
Not diagnosed, not the voice change, and left failing rather than silenced. The
check's premise is cross-run bit-determinism, which is what made it strong and
also what makes it brittle.

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-30 09:20:13 +00:00
parent 2712efe2f2
commit 73125074c6
5 changed files with 200 additions and 10 deletions

View File

@@ -124,6 +124,9 @@ pub struct Config {
pub se: Vec<(String, CueSpec)>,
pub bgm: Vec<(String, BgmSpec)>,
pub voice: Presentation,
/// Declared XMA `byte_size` -> stereo-downmix coefficient, from
/// `authored/audio.json`. Empty means no region is weighted.
pub stream_weights: std::collections::BTreeMap<usize, f64>,
}
/// Read `authored/audio.json`, or `None` when there is no such file.
@@ -175,10 +178,28 @@ pub fn load(authored: &Path) -> Result<Option<Config>> {
.with_context(|| format!("authored/audio.json: voice.presentation {v}"))?,
None => Presentation::default(),
};
// Declared `byte_size` -> stereo-downmix coefficient. Keyed by size so the
// exporter can CHECK the stream is the one the measurement describes.
let mut stream_weights: std::collections::BTreeMap<usize, f64> = Default::default();
if let Some(serde_json::Value::Object(m)) = file.voice.get("stream_weights") {
for (k, v) in m {
if k == "_" {
continue;
}
let size: usize = k
.parse()
.with_context(|| format!("authored/audio.json: voice.stream_weights key {k}"))?;
let w = v.get("weight").and_then(serde_json::Value::as_f64).with_context(|| {
format!("authored/audio.json: voice.stream_weights.{k} has no numeric weight")
})?;
stream_weights.insert(size, w);
}
}
Ok(Some(Config {
se: entries(file.se, "se")?,
bgm: entries(file.bgm, "bgm")?,
voice,
stream_weights,
}))
}
@@ -188,6 +209,10 @@ pub fn load(authored: &Path) -> Result<Option<Config>> {
/// be a codec artefact.
const VORBIS_Q: &str = "5";
/// Bytes of RIFF header `to_xma_riffs` prepends to a chunk. The decoder's
/// `byte_size` is the payload, so a size comparison must subtract it.
const RIFF_HEADER: usize = 60;
pub struct Exported {
pub name: String,
pub file: String,
@@ -649,6 +674,7 @@ pub fn export_voice<S: DiscSource + ?Sized>(
movie: &str,
video_duration_s: Option<f32>,
presentation: Presentation,
stream_weights: &std::collections::BTreeMap<usize, f64>,
) -> Result<Option<Exported>> {
use sylpheed_formats::slb::VoiceLang;
@@ -839,11 +865,36 @@ pub fn export_voice<S: DiscSource + ?Sized>(
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()
));
// 🔴 MEASURED POSITIONAL WEIGHTS where every kept stream's declared size
// is in the authored table, and the count divisor otherwise.
//
// The table is keyed by the decoder's own `byte_size`, so this is a
// CHECK and not an assumption: if the streams in front of us are not the
// ones the measurement describes, the sizes do not match and the mix
// falls back. That mattered once already -- on 2026-08-30 these sizes
// did NOT fit the region the resolver returned, which is how a
// 238-packet late start was found. Applied positionally instead, the
// weights would have gone onto the wrong streams in silence.
let sizes: Vec<usize> = keep.iter().map(|&i| riffs[i].len() - RIFF_HEADER).collect();
let ws: Option<Vec<f64>> = sizes.iter().map(|s| stream_weights.get(s).copied()).collect();
match ws {
Some(w) if w.len() == staged.len() => {
// Weights sum to one, so the total is the movie's own and what
// they distribute is the balance between three positions.
let terms: Vec<String> = w
.iter()
.enumerate()
.map(|(i, g)| format!("[m{i}]volume={g:.4}[w{i}]"))
.collect();
parts.extend(terms);
let ins: String = (0..staged.len()).map(|i| format!("[w{i}]")).collect();
parts.push(format!("{ins}amix=inputs={}:normalize=0[a]", staged.len()));
}
_ => {
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());
@@ -895,8 +946,8 @@ pub fn export_voice<S: DiscSource + ?Sized>(
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}",
presentation `{}`, folded to mono on each stream's own live channels. \
Chunks found, in region order: {}.{}{against}",
riffs.len(),
staged.len(),
match presentation {
@@ -904,6 +955,21 @@ pub fn export_voice<S: DiscSource + ?Sized>(
Presentation::Loudest => "loudest",
Presentation::HighestRate => "highest_rate",
},
// The INVENTORY, not just what was dropped. A reader mapping these
// onto the decoder's own `byte_size` values -- which is how the
// stream-to-speaker assignment is indexed -- needs every chunk's
// size, and the dropped list only ever showed the ones that lost.
(0..all.len())
.map(|i| {
format!(
"chunk {i} {} B ({:.3} s{})",
riffs[i].len(),
lengths[i],
if silent.contains(&i) { ", SILENT" } else { "" }
)
})
.collect::<Vec<_>>()
.join("; "),
if dropped.is_empty() {
String::new()
} else {

View File

@@ -451,7 +451,8 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
// there is no `authored/audio.json` -- the voice binding is decoded,
// so the dialogue exports either way and only the choice defaults.
let want = audio_cfg.as_ref().map(|c| c.voice).unwrap_or_default();
match audio::export_voice(&source, out, stem, *len, want)? {
let weights = audio_cfg.as_ref().map(|c| c.stream_weights.clone()).unwrap_or_default();
match audio::export_voice(&source, out, stem, *len, want, &weights)? {
Some(a) => {
// 🔴 A TOP-LEVEL WARNING, not just a `why` on the entry. The
// export is known to be missing audio the game plays, and