Files
Sylpheed/crates/sylpheed-export/src/audio.rs
Sylpheed port agent 4817e5ea9e 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
2026-08-29 15:07:30 +00:00

810 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Menu audio: disc XMA → Ogg Vorbis, because Godot 4 plays Vorbis natively and
//! will never be taught to read XMA or to open `sound.pak`.
//!
//! Two kinds of thing come out of here and they are not symmetric:
//!
//! * **SE cues** — three short mono waves out of `Static.slb`, one per menu
//! event. Where each one lives was *measured off the running game* (HANDOFF
//! Q8) and is **not on the disc in any findable form**.
//! * **BGM** — one bank of `sound.pak`, which is **two stems of one
//! performance** (HANDOFF Q10). They are summed here into a single file.
//!
//! ## Neither table lives in this file
//!
//! Both come from `authored/audio.json`, and that is the point of the module
//! rather than an accident of configuration. MISSION §3: a value that is
//! *measured* rather than *decoded* lives in `authored/`, carries a `why`, and
//! is deleted the day the disc states it. A measured offset compiled into a Rust
//! `const` is a measurement wearing the costume of a decoded field — it reads
//! like the exporter derived it, and nobody deletes it, because nobody can see
//! it. Contrast [`crate::video::MOVIES`], which *is* a `const` here: Q9 decoded
//! that mapping from the movie manifest on the disc.
//!
//! ## The assembly is not reimplemented here
//!
//! `sylpheed_formats::media` owns every question of the form *"which bytes
//! belong together"* — segment-spanning reads, multi-sub-wave banks, and the
//! delimiter-less `Static.slb` where a wave is only `(offset, packet_count)`.
//! This module asks it for `RIFF`s and converts them. The seam is deliberate:
//! everything before it is disc knowledge, everything after it is a codec
//! choice, and re-deriving the first half here is exactly the mistake the
//! mission names.
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use sylpheed_formats::media::{self, DiscSource};
/// One menu sound effect, and where its wave sits in a delimiter-less bank.
///
/// `offset` is a string so the file can carry `"0x1ec0"` — the form the RE
/// finding is written in. A reader comparing the two should not have to convert
/// 125 632 in their head to believe they match.
#[derive(Deserialize)]
pub struct CueSpec {
pub bank: String,
pub offset: String,
pub packets: usize,
pub channels: u8,
pub rate: u32,
/// The game's own cue identifier, where one has been *guessed by name*.
/// Absent means nobody claimed one — never that the binding is unknown.
#[serde(default)]
pub name_match: Option<String>,
pub why: String,
}
impl CueSpec {
fn offset(&self, event: &str) -> Result<usize> {
let s = self.offset.trim();
let parsed = match s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
Some(hex) => usize::from_str_radix(&hex.replace('_', ""), 16),
None => s.replace('_', "").parse(),
};
parsed.with_context(|| format!("cue `{event}`: `{s}` is not an offset"))
}
}
/// One music bank and what to do with it.
#[derive(Deserialize)]
pub struct BgmSpec {
pub bank: String,
/// What happens at the end of the file. Carried through to the manifest so
/// the runtime does not have to reach into `authored/` to find out, and so
/// that the *why* travels with the decision.
#[serde(default)]
pub r#loop: Option<String>,
pub why: String,
#[serde(default)]
pub loop_why: Option<String>,
#[serde(default)]
pub stems_why: Option<String>,
}
/// `authored/audio.json`, with the documentation keys dropped.
pub struct Config {
pub se: Vec<(String, CueSpec)>,
pub bgm: Vec<(String, BgmSpec)>,
}
/// Read `authored/audio.json`, or `None` when there is no such file.
///
/// Absent is not an error: an export with no audio is what every milestone
/// before P6 produced, and it should stay possible to take one.
pub fn load(authored: &Path) -> Result<Option<Config>> {
let path = authored.join("audio.json");
if !path.exists() {
return Ok(None);
}
#[derive(Deserialize)]
struct File {
#[serde(default)]
se: BTreeMap<String, serde_json::Value>,
#[serde(default)]
bgm: BTreeMap<String, serde_json::Value>,
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
let file: File = serde_json::from_str(&raw)
.with_context(|| format!("parse {}", path.display()))?;
// `_` is the house convention for a prose block explaining the section it
// sits in -- see `authored/flow.json` and `authored/timing.json`. It is
// documentation, not an entry, and the schema must skip it rather than
// force the reasoning out of the file that holds the decision.
fn entries<T: serde::de::DeserializeOwned>(
m: BTreeMap<String, serde_json::Value>,
what: &str,
) -> Result<Vec<(String, T)>> {
m.into_iter()
.filter(|(k, _)| k != "_")
.map(|(k, v)| {
let parsed = serde_json::from_value(v)
.with_context(|| format!("authored/audio.json: {what}.{k}"))?;
Ok((k, parsed))
})
.collect()
}
Ok(Some(Config {
se: entries(file.se, "se")?,
bgm: entries(file.bgm, "bgm")?,
}))
}
/// Vorbis quality. `-q:a 5` is ffmpeg's usual transparent-ish setting and is
/// what [`crate::video`] already uses for the movies' audio; using one value
/// across the export means a level difference between a cue and a movie cannot
/// be a codec artefact.
const VORBIS_Q: &str = "5";
pub struct Exported {
pub name: String,
pub file: String,
pub command: String,
pub why: String,
/// dBFS peak of the decoded result. Recorded because the BGM sum can clip
/// and a clipped file is not visibly different from a correct one.
pub peak_dbfs: Option<f32>,
/// Seconds, as ffprobe reads them back off the finished file. Recorded
/// because the cue durations are the one thing about the SE export that an
/// outside finding predicts, so they are the one thing that can be checked.
pub duration_s: Option<f32>,
pub kind: &'static str,
/// The game's own identifier where it is a name match, never a measurement.
pub name_match: Option<String>,
/// 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<String>,
/// How many sub-waves `media` returned for a bank. Carried out of here so
/// the caller can warn when it contradicts HANDOFF's census -- this module
/// does not get to decide that one of them is not a stem.
pub sub_waves: usize,
}
/// Run ffmpeg, writing to a temp name and renaming on success.
///
/// The rename is not tidiness: the filesystem is shared with another agent, and
/// a reader that catches a half-written Ogg gets a confident wrong duration
/// rather than an error. `docs/port/AUDIO-VERIFICATION.md` records that this
/// already happened once on a video.
fn run_ffmpeg(argv: &[String], out: &Path) -> Result<()> {
// The extension goes LAST, not the `.partial`. ffmpeg picks its muxer from
// the output filename, so `.back.ogg.partial` is not a slightly uglier
// temp name -- it is a hard failure before a byte is written: "Unable to
// choose an output format". `video.rs` already had this shape; this
// function was written from scratch and did not.
let stem = out.file_stem().unwrap_or_default().to_string_lossy().into_owned();
let ext = out.extension().unwrap_or_default().to_string_lossy().into_owned();
let partial = out.with_file_name(format!(".{stem}.partial.{ext}"));
let mut argv = argv.to_vec();
let last = argv.len() - 1;
argv[last] = partial.display().to_string();
let res = Command::new("ffmpeg")
.args(&argv)
.output()
.context("run ffmpeg -- is it on PATH?")?;
if !res.status.success() {
let _ = std::fs::remove_file(&partial);
bail!(
"ffmpeg failed writing {}:\n{}",
out.display(),
String::from_utf8_lossy(&res.stderr)
);
}
std::fs::rename(&partial, out)?;
Ok(())
}
/// Peak level and duration of a finished file.
///
/// Measured rather than assumed because the BGM is a **sum of two stems** and a
/// sum can clip, and because silence is the audio failure that looks like
/// success: a file of exactly the right duration, full of zeroes. Both numbers
/// pass every check that is not looking for them, so the export looks for them.
fn measure(path: &Path) -> (Option<f32>, Option<f32>) {
let out = Command::new("ffmpeg")
.args(["-hide_banner", "-v", "info", "-i"])
.arg(path)
.args(["-af", "astats=measure_perchannel=none", "-f", "null", "-"])
.output();
let Ok(out) = out else { return (None, None) };
let text = String::from_utf8_lossy(&out.stderr).into_owned();
// `astats` writes through the filter log, so every line carries a
// `[Parsed_astats_0 @ 0x…] ` prefix. Matching on the line START silently
// finds nothing and reports "peak unmeasured", which is the failure this
// measurement exists to catch -- so it is found as a SUBSTRING.
const KEY: &str = "Peak level dB:";
let peak = text
.lines()
.find_map(|l| l.split_once(KEY)?.1.trim().parse().ok());
let dur = Command::new("ffprobe")
.args([
"-v", "error", "-show_entries", "format=duration",
"-of", "csv=p=0",
])
.arg(path)
.output()
.ok()
.and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok());
(peak, dur)
}
/// Write one XMA `RIFF` beside the output so ffmpeg has something to open.
///
/// Kept next to the result rather than in `/tmp` so a failed export leaves the
/// intermediate where the person debugging it will look, and removed on success
/// so the tree holds only formats a modder can open (MODDING rule 3).
fn stage_riff(dir: &Path, stem: &str, riff: &[u8]) -> Result<PathBuf> {
let path = dir.join(format!(".{stem}.xma.wav"));
std::fs::write(&path, riff).with_context(|| format!("write {}", path.display()))?;
Ok(path)
}
/// The menu cues, as `audio/se/<event>.ogg`.
pub fn export_cues<S: DiscSource + ?Sized>(
source: &S,
out: &Path,
cues: &[(String, CueSpec)],
) -> Result<Vec<Exported>> {
if cues.is_empty() {
return Ok(Vec::new());
}
let dir = out.join("audio/se");
std::fs::create_dir_all(&dir)?;
let mut done = Vec::new();
for (event, cue) in cues {
let offset = cue.offset(event)?;
// `media` owns the assembly. Asking it for the RIFF rather than reading
// `Static.slb` here is the whole point of the seam -- and it REFUSES a
// short read rather than returning a truncated stream, because a
// truncated XMA decodes to plausible-sounding garbage.
let riff = media::se_wave_riff(
source, &cue.bank, offset, cue.packets, cue.channels, cue.rate,
)
.map_err(anyhow::Error::msg)
.with_context(|| format!("assemble the {event} cue"))?;
let staged = stage_riff(&dir, event, &riff)?;
let ogg = dir.join(format!("{event}.ogg"));
let argv: Vec<String> = [
"-hide_banner", "-loglevel", "error", "-y",
"-i", &staged.display().to_string(),
"-c:a", "libvorbis", "-q:a", VORBIS_Q,
&ogg.display().to_string(),
]
.iter()
.map(|s| s.to_string())
.collect();
let command = format!("ffmpeg {}", argv.join(" "));
run_ffmpeg(&argv, &ogg)?;
let (peak, dur) = measure(&ogg);
std::fs::remove_file(&staged).ok();
done.push(Exported {
name: event.clone(),
file: format!("audio/se/{event}.ogg"),
command,
why: format!(
"{} AUTHORED because it is measured, not decoded: authored/audio.json \
se.{event}. Located in {} at {:#x} for {} packet(s); the ASSEMBLY is \
sylpheed_formats::media::se_wave_riff, which refuses a short read.",
cue.why, cue.bank, offset, cue.packets
),
peak_dbfs: peak,
duration_s: dur,
kind: "se",
name_match: cue.name_match.clone(),
loop_mode: None,
sub_waves: 1,
});
}
Ok(done)
}
/// One music bank, as a single `audio/bgm/<name>.ogg`.
///
/// **The two stems are summed, not concatenated and not split into two files.**
///
/// HANDOFF Q10: a bank's sub-waves are two stems of one performance, played
/// together — sample-synchronous, equal duration, on all 32 banks.
/// Concatenating them is explicitly wrong. Emitting two files would be wrong
/// here for a different reason: MODDING rule 1 is *one logical asset, one
/// file*, and a modder who had to line two stems up by hand would be
/// reassembling exactly what the exporter exists to resolve.
///
/// **The sum is scaled by 1/n, and an earlier version of this comment argued the
/// opposite.** It said `normalize=0` sums at unity "because halving is a mix
/// decision nobody made". That was wrong twice over. Unity summing IS a decision
/// — and it is the one that can clip, which it duly did: `BGM_103` came out at
/// **+1.8 dBFS**. And 1/n is not a taste call but the smallest constant that
/// makes an n-input sum of unity-scale signals provably clip-free, which is the
/// same reasoning `video.rs` already uses for its 0.4142-normalised 5.1 downmix.
/// It is written out as an explicit `volume=` rather than left to `amix`'s
/// `normalize=1` default so the coefficient appears in the manifest's command
/// line: a default is a decision nobody made, and it can move under an ffmpeg
/// upgrade.
///
/// It preserves the stems' relative balance exactly, which is the only thing
/// about the sum that HANDOFF Q10 actually settles. The peak is still measured
/// and reported.
///
/// The file is named for the **role** (`main_menu`), not for the bank
/// (`BGM_001`). Which bank plays here is authored and expected to change; the
/// role is what the runtime asks for, and a rename of the disc asset should not
/// be a change to the Godot project.
pub fn export_bgm<S: DiscSource + ?Sized>(
source: &S,
out: &Path,
role: &str,
spec: &BgmSpec,
) -> Result<Option<Exported>> {
let riffs = match media::sound_bank_riffs(source, &spec.bank) {
Ok(r) if !r.is_empty() => r,
Ok(_) => return Ok(None),
// "This disc does not have that bank" is a MISSING ASSET, not a broken
// exporter: the manifest carries a warning and everything else still
// exports. Any other failure -- a short read, a malformed bank -- is a
// real error and stops the run, because a partly-read bank produces a
// file that plays.
Err(e) if e.contains("not present in sound.pak") => return Ok(None),
Err(e) => bail!("{}: {e}", spec.bank),
};
let dir = out.join("audio/bgm");
std::fs::create_dir_all(&dir)?;
let mut staged = Vec::new();
for (i, riff) in riffs.iter().enumerate() {
staged.push(stage_riff(&dir, &format!("{role}.{i}"), riff)?);
}
let ogg = dir.join(format!("{role}.ogg"));
let mut argv: Vec<String> = ["-hide_banner", "-loglevel", "error", "-y"]
.iter()
.map(|s| s.to_string())
.collect();
for s in &staged {
argv.push("-i".into());
argv.push(s.display().to_string());
}
if staged.len() > 1 {
argv.push("-filter_complex".into());
argv.push(format!(
"amix=inputs={n}:normalize=0,volume={:.6}",
1.0 / staged.len() as f64,
n = staged.len()
));
}
argv.extend(
["-c:a", "libvorbis", "-q:a", VORBIS_Q, &ogg.display().to_string()]
.iter()
.map(|s| s.to_string()),
);
let command = format!("ffmpeg {}", argv.join(" "));
run_ffmpeg(&argv, &ogg)?;
let (peak, dur) = measure(&ogg);
for s in &staged {
std::fs::remove_file(s).ok();
}
let mut why = format!(
"{} authored/audio.json bgm.{role} names bank {}. Its {} \
sub-wave(s) are SUMMED into one file and the sum is scaled by 1/{}, \
which is the smallest constant that cannot clip.",
spec.why,
spec.bank,
riffs.len(),
riffs.len()
);
if let Some(s) = &spec.stems_why {
why.push(' ');
why.push_str(s);
}
if let Some(s) = &spec.loop_why {
why.push(' ');
why.push_str(s);
}
Ok(Some(Exported {
name: role.to_string(),
file: format!("audio/bgm/{role}.ogg"),
command,
why,
peak_dbfs: peak,
duration_s: dur,
kind: "bgm",
name_match: None,
loop_mode: spec.r#loop.clone(),
sub_waves: riffs.len(),
}))
}
/// One cutscene's voice-over, as `audio/voice/<movie>.ogg`.
///
/// ## Why this is a separate file from the movie at all
///
/// A human play-test heard music under the intro and no dialogue, and the
/// obvious reading — "the transcode dropped a channel" — is wrong. `ADV.wmv`
/// genuinely carries **music and effects only**. On this disc a cutscene's voice
/// is a *different asset*: one continuous XMA stream in `sound.pak`, bound to
/// the movie by the movie manifest in `tables.pak` (`ADV` → `VOICE_ADV`). It was
/// not dropped by [`crate::video`]; it was never exported, because nothing here
/// asked for it.
///
/// ## The binding is resolved, never matched by name
///
/// `sylpheed_formats::media::resolve_movie_voice_region` walks
/// movie → cue token → sound id → byte region. It is the only route taken here,
/// and the reason is that the cheap route looks correct on exactly the movies a
/// person would check first. Measured on the retail disc:
///
/// | movie | region | inside the bank named after it? |
/// |---|---|---|
/// | `ADV` | 433 930 240…437 044 592 | yes |
/// | `S00A` | 452 798 464…455 499 120 | yes |
/// | `RT01A` | 437 044 592…437 345 648 | **no — it is inside `VOICE_ADV.slb`** |
///
/// So `VOICE_<movie>.slb` is a name that happens to hold the right audio twice
/// out of three, and the two it gets right are the two in this port's scope.
/// Reading the bank by name would have shipped, verified clean, and been wrong
/// for the radio cutscenes the moment anybody extended the export.
///
/// ## What a `None` means, and what it must not become
///
/// A movie whose region does not resolve is **genuinely unvoiced** — that is a
/// real answer for most `hokyu_*` resupply cutscenes, and the corpus already
/// paid for the alternative: resolving unbound movies through a shared demo line
/// played the *wrong recording*. Nothing is substituted. A `\Movie\` token that
/// resolved a clip but not a region stays silent for the same reason
/// `sylpheed-viewer` keeps it silent: its raw `.slb` is off by one chunk, so it
/// is not this movie's dialogue.
///
/// ## Three choices made here, and the reason each is not a guess
///
/// * **One file per movie** (MODDING rule 1), and the region's `RIFF`s are
/// **summed, not concatenated** — the reverse of what this function did when
/// it was first written an hour earlier. Concatenating produced a 359 s voice
/// track for a 137 s movie.
///
/// ## The chunks are stems, and only a measurement showed it
///
/// A resolved region decodes to several chunks, and the two readings — segments
/// to join end to end, or stems to mix — look identical in the bytes. Decoded
/// and timed against the movies' own lengths:
///
/// | movie | movie | chunk 0 | chunk 1 | chunk 2 |
/// |---|---|---|---|---|
/// | `ADV` | 137.437 s | 84.553 | **137.324** | **137.324** |
/// | `S00A` | 93.779 s | 68.072 | **93.694** | **93.694** |
/// | `RT01A` | — | 0.009 | **34.034** | — |
///
/// Chunks 1 and 2 are **equal to six decimals and each span the whole movie**.
/// That is exactly HANDOFF Q10's decoded shape — *a bank is two stems of one
/// 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 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.
/// * **No sync offset.** The voice plays from the video's first frame, so the
/// runtime needs no delay and none is authored. The decoded length is
/// recorded beside the movie's own length in the manifest so a disagreement
/// is visible rather than absorbed.
pub fn export_voice<S: DiscSource + ?Sized>(
source: &S,
out: &Path,
movie: &str,
video_duration_s: Option<f32>,
) -> Result<Option<Exported>> {
use sylpheed_formats::slb::VoiceLang;
// English only: MISSION §7 puts localisation beyond English out of scope.
// The language is a parameter of the resolution, not of the file layout, so
// adding Japanese later is a second call and a second file, not a re-think.
let lang = VoiceLang::English;
let region = media::resolve_movie_voice_region(source, movie, lang);
let Some((start, end)) = region else {
return Ok(None);
};
let riffs = media::voice_region_riffs(source, start, end)
.map_err(anyhow::Error::msg)
.with_context(|| format!("decode the {movie} voice region"))?;
if riffs.is_empty() {
return Ok(None);
}
let dir = out.join("audio/voice");
std::fs::create_dir_all(&dir)?;
let mut all = Vec::new();
for (i, riff) in riffs.iter().enumerate() {
all.push(stage_riff(&dir, &format!("{movie}.{i}"), riff)?);
}
// Classify before mixing. XMA declares no duration, so each chunk is decoded
// and timed -- the only way to tell a stem from the leading region, and the
// measurement that showed concatenation to be wrong here.
let probed: Vec<(f32, f32)> = all.iter().map(|p| decoded_chunk(p)).collect();
let lengths: Vec<f32> = probed.iter().map(|&(d, _)| d).collect();
// A DIGITALLY SILENT chunk is dropped before anything else, and that is
// arithmetic rather than a judgement about content: it contributes nothing
// to a mix, and counting it in the 1/n normalisation costs 6.02 dB for
// nothing. `S00A`'s second full-length chunk is exactly this -- 4 497 300
// samples of zeroes, peak -inf -- and summing it is why that movie's voice
// came out at -16.2 dBFS against a source peaking at -4.2.
let silent: Vec<usize> = (0..all.len()).filter(|&i| probed[i].1 <= -90.0).collect();
let longest = (0..all.len())
.filter(|i| !silent.contains(i))
.map(|i| lengths[i])
.fold(0.0f32, f32::max);
// A tie at 1 ms. The two stems agree to six decimals and the chunk that is
// not one of them misses by tens of seconds, so nothing sits near this
// bound: it separates the measured cases without being a tuned threshold.
let keep: Vec<usize> = (0..all.len())
.filter(|&i| !silent.contains(&i) && (longest - lengths[i]).abs() < 0.001)
.collect();
let dropped: Vec<String> = (0..all.len())
.filter(|i| !keep.contains(i))
.map(|i| {
format!(
"chunk {i} ({:.3} s, {} B, peak {})",
lengths[i],
riffs[i].len(),
if silent.contains(&i) {
"SILENT".to_string()
} else {
format!("{:.1} dBFS", probed[i].1)
}
)
})
.collect();
if keep.is_empty() {
return Ok(None);
}
let staged: Vec<PathBuf> = keep.iter().map(|&i| all[i].clone()).collect();
// 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 = 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"));
let mut argv: Vec<String> = ["-hide_banner", "-loglevel", "error", "-y"]
.iter()
.map(|s| s.to_string())
.collect();
for s in &staged {
argv.push("-i".into());
argv.push(s.display().to_string());
}
// SUMMED at 1/n, with the coefficient written out rather than left to
// `amix`'s `normalize=1` default, so it appears in the manifest's command
// line. Same reasoning as `export_bgm`: a default is a decision nobody made
// and it can move under an ffmpeg upgrade.
let filter = format!(
"{}amix=inputs={n}:normalize=0,volume={:.6}{fold}[a]",
(0..staged.len()).map(|i| format!("[{i}:a]")).collect::<String>(),
1.0 / staged.len() as f64,
n = staged.len(),
);
argv.push("-filter_complex".into());
argv.push(filter);
argv.push("-map".into());
argv.push("[a]".into());
argv.extend(
["-c:a", "libvorbis", "-q:a", VORBIS_Q, &ogg.display().to_string()]
.iter()
.map(|s| s.to_string()),
);
let command = format!("ffmpeg {}", argv.join(" "));
run_ffmpeg(&argv, &ogg)?;
let (peak, dur) = measure(&ogg);
for s in &all {
std::fs::remove_file(s).ok();
}
let against = match (dur, video_duration_s) {
(Some(d), Some(v)) => format!(
" Decoded length {d:.3} s against the movie's {v:.3} s (delta {:+.3} s); \
NOT trimmed to fit -- a clamp would hide a resolution error, and the \
runtime stops the voice when the video ends.",
d - v
),
_ => String::new(),
};
Ok(Some(Exported {
name: movie.to_string(),
file: format!("audio/voice/{movie}.ogg"),
command,
why: format!(
"DECODED, not authored: the movie manifest in tables.pak binds {movie} to a \
voice cue, and sylpheed_formats::media::resolve_movie_voice_region walks \
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. 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 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(),
if dropped.is_empty() {
String::new()
} else {
format!(
" 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(", ")
)
}
),
peak_dbfs: peak,
duration_s: dur,
kind: "voice",
name_match: None,
loop_mode: None,
sub_waves: riffs.len(),
}))
}
/// How many channels a staged `RIFF` declares, per ffprobe.
fn probe_channels(path: &Path) -> Option<u8> {
let out = Command::new("ffprobe")
.args([
"-v", "error", "-select_streams", "a:0",
"-show_entries", "stream=channels", "-of", "csv=p=0",
])
.arg(path)
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
}
/// Seconds a finished media file runs, per ffprobe.
///
/// Exposed so the caller can hand [`export_voice`] the movie's own length: the
/// voice is a separate asset with no shared container to agree with, so the only
/// way a resolution error shows up is a length that does not match the picture.
pub fn probe_duration(path: &Path) -> Option<f32> {
let out = Command::new("ffprobe")
.args([
"-v", "error", "-show_entries", "format=duration",
"-of", "csv=p=0",
])
.arg(path)
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
}
/// Seconds and peak dBFS that one staged XMA `RIFF` decodes to.
///
/// XMA carries no duration in its header, so the chunk is decoded to PCM and the
/// result measured. That is expensive and it is the only instrument that can
/// separate these chunks at all: `ffprobe` on the `RIFF` itself returns `N/A`
/// for duration, which a caller that trusted it would read as zero, and the
/// corpus records `sylpheed-cli audio info` mis-reading the same headers as
/// 16 channels at 4 310 Hz.
///
/// A silent chunk returns `-inf`, which the caller drops.
fn decoded_chunk(riff: &Path) -> (f32, f32) {
let wav = riff.with_extension("probe.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 out = if ok {
let (peak, dur) = measure(&wav);
(dur.unwrap_or(0.0), peak.unwrap_or(f32::NEG_INFINITY))
} else {
(0.0, f32::NEG_INFINITY)
};
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
}