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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user