Files
Sylpheed/crates/sylpheed-export/src/audio.rs
Sylpheed port agent 2ad839460c port: P6 -- the menu has sound, and the BGM I "chose" was decoded all along
The three Static.slb cues and the menu bed now export to Ogg Vorbis and play.
`sylpheed_formats::media` does the assembly; nothing in port/ has heard of XMA.

Three things this milestone got wrong before it got right, all recorded in
docs/port/DECISIONS.md because the corrections are the useful part:

1. The cue offsets were a Rust `const` in the exporter. They are MEASURED, not
   decoded -- a measured value compiled into the exporter is a measurement
   wearing the costume of a decoded field, and nobody deletes it because nobody
   can see it. They are authored/audio.json now.

2. I picked BGM_001 and wrote a careful `why` calling the choice arbitrary. The
   menu's music is BGM_103, and it is in HANDOFF at 9ca1eb5 -- the exact commit
   BLOCKED.md says that row was reconciled against. Not stale: wrong when
   written. I had summarised a negative without its reach, so "the TABLES cannot
   say which BGM a screen plays" became "it is not on the disc". One word of
   scope was the whole answer, and the export failed only because BGM_001
   without its .slb extension hashes to nothing. That is luck, not design.

3. The comment above the BGM sum argued for unity gain "because halving is a mix
   decision nobody made". It clipped at +1.8 dBFS. 1/n is the smallest constant
   that provably cannot clip -- the same reasoning video.rs already carried for
   its 5.1 downmix, in this repository, unread.

Unsettled and shipped as such: media::sound_bank_riffs returns THREE sub-waves
for BGM_103.slb where HANDOFF Q10's census says exactly two (the third is the
leading headerless region slb.rs emits for the voice path). The exporter sums all
three and writes a manifest warning, because which bytes belong together is the
decoders' question, not this exporter's -- and dropping one would destroy the
evidence, since a corrected export looks exactly like a correct one. Raised with
the Decoder; row in BLOCKED.md.

The gate is a null control, not a peak reading. A master-bus WAV that is
non-silent proves nothing -- the bed alone would look identical. So the same
scripted walk was run with <- in place of <v>, which fires no cue (Q5, measured),
and the difference is one 0.55 s burst at t=1.10 s and silence everywhere else.
The first attempt at that control returned bit-identical zero and I nearly filed
it as "cues never reach the bus": both runs ended at 1.115 s and the first press
lands at 1.17 s. A null result from an instrument that was not running is not a
null result.

Refutation attempt: HANDOFF Q8's three cue durations. They looked attackable --
0.133/0.172/0.169 s per packet, no shared rate -- but an XMA1 packet carries a
variable number of 512-sample frames, and the three come to 50.0/32.3/95.3
frames. Measured off the decoded Ogg: 0.533, 0.344, 1.016 s, every published
digit. SURVIVES, with its reach stated -- it confirms the assembly path and my
transcription, not the event bindings, which only an oracle can retake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WM5XL4HfrHuxz8RiMWdCMC
2026-08-29 12:29:13 +00:00

423 lines
17 KiB
Rust

//! 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(),
}))
}