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
This commit is contained in:
422
crates/sylpheed-export/src/audio.rs
Normal file
422
crates/sylpheed-export/src/audio.rs
Normal file
@@ -0,0 +1,422 @@
|
||||
//! 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(),
|
||||
}))
|
||||
}
|
||||
@@ -12,7 +12,9 @@
|
||||
//! * a `buttons` entry naming an element that is not a button, or out of
|
||||
//! resting-Y order;
|
||||
//! * a sprite path that does not exist, or a PNG that does not decode;
|
||||
//! * a name presented as recovered when it was authored.
|
||||
//! * a name presented as recovered when it was authored;
|
||||
//! * an audio file that is silent or clips -- the two audio failures that pass
|
||||
//! every check that is not looking for them.
|
||||
//!
|
||||
//! It deliberately does **not** check that the export matches the disc. That is
|
||||
//! what `sylpheed-cli screen render` is for.
|
||||
@@ -267,6 +269,8 @@ pub fn run(root: &Path) -> Result<usize> {
|
||||
check_screen(root, file, &mut errors)?;
|
||||
}
|
||||
|
||||
check_audio(root, &m, &mut errors);
|
||||
|
||||
if !errors.is_empty() {
|
||||
for e in &errors {
|
||||
eprintln!(" ✗ {e}");
|
||||
@@ -275,3 +279,80 @@ pub fn run(root: &Path) -> Result<usize> {
|
||||
}
|
||||
Ok(screens.len())
|
||||
}
|
||||
|
||||
/// The `audio` array, checked the way a consumer would have to.
|
||||
///
|
||||
/// Two of these are content checks rather than schema checks, and they are here
|
||||
/// on purpose. `docs/port/AUDIO-VERIFICATION.md` names silence as "the failure
|
||||
/// that looks like success": a file of exactly the right duration, the right
|
||||
/// channel count and the right size, full of zeroes, because something opened
|
||||
/// the wrong thing. Every structural check passes it. So does clipping, which
|
||||
/// the BGM can produce because it is a **sum of two stems** at unity gain.
|
||||
///
|
||||
/// The exporter measures both at export time and writes them here; this refuses
|
||||
/// the tree if what it wrote is a file nobody would want to play. Neither is a
|
||||
/// judgement about whether the audio is the RIGHT audio — nothing in this
|
||||
/// binary can know that, and `docs/port/BLOCKED.md` says which parts are still
|
||||
/// authored guesses.
|
||||
fn check_audio(root: &Path, m: &Value, errors: &mut Vec<String>) {
|
||||
let Some(audio) = m.get("audio").and_then(Value::as_array) else {
|
||||
// Absent is correct for every export taken before P6.
|
||||
return;
|
||||
};
|
||||
for a in audio {
|
||||
let name = a.get("name").and_then(Value::as_str).unwrap_or("?");
|
||||
let kind = a.get("kind").and_then(Value::as_str).unwrap_or("");
|
||||
if !matches!(kind, "se" | "bgm") {
|
||||
errors.push(format!(
|
||||
"manifest.json: audio `{name}` has kind {kind:?}, which a consumer cannot dispatch on"
|
||||
));
|
||||
}
|
||||
for key in ["file", "command", "why"] {
|
||||
if a.get(key).and_then(Value::as_str).is_none_or(str::is_empty) {
|
||||
errors.push(format!("manifest.json: audio `{name}` has no `{key}`"));
|
||||
}
|
||||
}
|
||||
let Some(file) = a.get("file").and_then(Value::as_str) else { continue };
|
||||
if !root.join(file).exists() {
|
||||
errors.push(format!("manifest.json: lists audio {file}, which does not exist"));
|
||||
continue;
|
||||
}
|
||||
match a.get("peak_dbfs").and_then(Value::as_f64) {
|
||||
None => errors.push(format!(
|
||||
"manifest.json: audio `{name}` carries no `peak_dbfs` -- it was not measured, \
|
||||
and silence is the audio failure that passes every check that is not looking \
|
||||
for it"
|
||||
)),
|
||||
Some(p) if p <= -90.0 => errors.push(format!(
|
||||
"{file}: peak is {p:.1} dBFS -- this file is silent"
|
||||
)),
|
||||
// The bound differs by kind, and the difference is the point. A
|
||||
// `bgm` is something WE combined -- a sum of stems -- so a peak at
|
||||
// or above full scale is our arithmetic and is refused outright. An
|
||||
// `se` is a single wave off the disc: it is mastered near full
|
||||
// scale, and a lossy decode of a near-full-scale signal overshoots
|
||||
// by a fraction of a dB (`confirm` lands at +0.18). Refusing that
|
||||
// would be refusing the disc's own mastering, and "fixing" it would
|
||||
// mean attenuating a game asset to make a number smaller.
|
||||
//
|
||||
// 🟡 +1.0 dB is a JUDGEMENT, not a measurement: a few tenths is
|
||||
// reconstruction overshoot, a whole dB is not. Nobody has measured
|
||||
// the overshoot distribution across a corpus of cues, and if a cue
|
||||
// ever trips this the right response is that measurement, not a
|
||||
// looser bound.
|
||||
Some(p) if kind == "bgm" && p >= 0.0 => errors.push(format!(
|
||||
"{file}: peak is {p:.1} dBFS -- a SUM we produced clips"
|
||||
)),
|
||||
Some(p) if kind != "bgm" && p > 1.0 => errors.push(format!(
|
||||
"{file}: peak is {p:.1} dBFS -- too far over full scale to be decode overshoot"
|
||||
)),
|
||||
Some(_) => {}
|
||||
}
|
||||
match a.get("duration_s").and_then(Value::as_f64) {
|
||||
Some(d) if d > 0.0 => {}
|
||||
_ => errors.push(format!(
|
||||
"{file}: no positive `duration_s` -- a zero-length asset plays as silence"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//!
|
||||
//! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope.
|
||||
|
||||
mod audio;
|
||||
mod check;
|
||||
mod video;
|
||||
mod screen;
|
||||
@@ -20,7 +21,7 @@ use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use sylpheed_formats::{media, pak::PakArchive, ui_layout};
|
||||
|
||||
/// The revision of `sylpheed-formats` this exporter is pinned to, recorded in
|
||||
/// every file it writes. Keep in step with `Cargo.toml` — it is what makes an
|
||||
@@ -78,6 +79,31 @@ struct ManifestVideo {
|
||||
why: &'static str,
|
||||
}
|
||||
|
||||
/// One exported audio file. Carries the same provenance a video does, plus the
|
||||
/// measured peak and duration: silence and clipping are the two audio failures
|
||||
/// that pass every check that is not looking for them.
|
||||
#[derive(Serialize)]
|
||||
struct ManifestAudio {
|
||||
/// `se` or `bgm`. The runtime dispatches on it, so it is a field rather
|
||||
/// than a prefix on `name` that a consumer would have to parse.
|
||||
kind: &'static str,
|
||||
name: String,
|
||||
file: String,
|
||||
command: String,
|
||||
why: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
peak_dbfs: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
duration_s: Option<f32>,
|
||||
/// The game's own cue identifier where one is a NAME MATCH. Absent means
|
||||
/// nobody has claimed one -- never that the binding is unknown.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name_match: Option<String>,
|
||||
/// What the runtime does at the end of the file, where that was authored.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
loop_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Manifest {
|
||||
format: &'static str,
|
||||
@@ -88,6 +114,8 @@ struct Manifest {
|
||||
screens: Vec<ManifestScreen>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
videos: Vec<ManifestVideo>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
audio: Vec<ManifestAudio>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -195,6 +223,21 @@ fn main() -> Result<()> {
|
||||
fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
let names = load_names(authored_dir)?;
|
||||
|
||||
// Built up as the export runs. A warning is a thing a CONSUMER of the tree
|
||||
// has to know about; it is not an error, and it is not a log line, because
|
||||
// the person who needs it reads `manifest.json` and never sees stdout.
|
||||
let mut warnings: Vec<String> = vec![
|
||||
"GP_TITLE screen builds only. No other archive, and only the two movies \
|
||||
MISSION section 6 puts in scope."
|
||||
.into(),
|
||||
"The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \
|
||||
layout child, so `is_build` cannot see them and no content rule can: element \
|
||||
count and design size both overlap with two-element fragments in other archives. \
|
||||
They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \
|
||||
which is a locator and not a claim -- see each one's name_why."
|
||||
.into(),
|
||||
];
|
||||
|
||||
// Derived output is regenerated wholesale: clear it, so a screen that stops
|
||||
// being exported stops existing rather than lingering as a stale file that
|
||||
// still validates.
|
||||
@@ -276,6 +319,68 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// P6. Both tables are AUTHORED, for two different reasons -- the cue offsets
|
||||
// because they were measured off the running game and are on the disc in no
|
||||
// findable form, the BGM choice because HANDOFF Q10 is a negative and
|
||||
// nothing states which track a menu plays. See `authored/audio.json`.
|
||||
let mut audio = Vec::new();
|
||||
match audio::load(authored_dir)? {
|
||||
None => println!(" no authored/audio.json -- no audio exported"),
|
||||
Some(cfg) => {
|
||||
let source = media::DirectorySource::new(disc);
|
||||
for a in audio::export_cues(&source, out, &cfg.se)? {
|
||||
println!(
|
||||
" se {:<8} -> {} ({})",
|
||||
a.name,
|
||||
a.file,
|
||||
describe(&a)
|
||||
);
|
||||
audio.push(ManifestAudio::from(a));
|
||||
}
|
||||
for (role, spec) in &cfg.bgm {
|
||||
match audio::export_bgm(&source, out, role, spec)? {
|
||||
Some(a) => {
|
||||
println!(
|
||||
" bgm {:<8} -> {} ({}, bank {}, {} sub-wave(s))",
|
||||
a.name,
|
||||
a.file,
|
||||
describe(&a),
|
||||
spec.bank,
|
||||
a.sub_waves
|
||||
);
|
||||
// HANDOFF Q10's census is "exactly two waves of
|
||||
// identical duration, 32/32 banks on the disc". When
|
||||
// `media` hands back a different number, SAY SO -- the
|
||||
// port does not get to decide that one of them is not a
|
||||
// stem, and silently summing an extra region into the
|
||||
// music is precisely the media-assembly mistake MISSION
|
||||
// section 2 names. The decoder's answer is what ships;
|
||||
// the disagreement is what gets reported.
|
||||
if a.sub_waves != 2 {
|
||||
warnings.push(format!(
|
||||
"audio/bgm/{role}.ogg: sylpheed_formats::media::sound_bank_riffs \
|
||||
returned {} sub-wave(s) for `{}`, but HANDOFF Q10's bank census \
|
||||
says a music bank is EXACTLY TWO waves of identical duration \
|
||||
(32/32 banks). All {} are summed, because choosing which to drop \
|
||||
is a decoding question and this exporter does not answer those. \
|
||||
See docs/port/BLOCKED.md.",
|
||||
a.sub_waves, spec.bank, a.sub_waves
|
||||
));
|
||||
}
|
||||
audio.push(ManifestAudio::from(a));
|
||||
}
|
||||
// Not an error: the authored bank may simply not be on this
|
||||
// disc, and the export of everything else is still good.
|
||||
None => warnings.push(format!(
|
||||
"authored/audio.json bgm.{role} names bank `{}`, which is not in \
|
||||
this disc's sound.pak -- no BGM exported for that role.",
|
||||
spec.bank
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let manifest = Manifest {
|
||||
format: "sylpheed.manifest/1",
|
||||
exporter: EXPORTER,
|
||||
@@ -283,16 +388,8 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
disc: disc.display().to_string(),
|
||||
screens,
|
||||
videos,
|
||||
warnings: vec![
|
||||
"P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive."
|
||||
.into(),
|
||||
"The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \
|
||||
layout child, so `is_build` cannot see them and no content rule can: element \
|
||||
count and design size both overlap with two-element fragments in other archives. \
|
||||
They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \
|
||||
which is a locator and not a claim -- see each one's name_why."
|
||||
.into(),
|
||||
],
|
||||
audio,
|
||||
warnings,
|
||||
};
|
||||
std::fs::write(
|
||||
out.join("manifest.json"),
|
||||
@@ -301,3 +398,37 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
println!("wrote {}/manifest.json", out.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
impl From<audio::Exported> for ManifestAudio {
|
||||
fn from(a: audio::Exported) -> Self {
|
||||
ManifestAudio {
|
||||
kind: a.kind,
|
||||
name: a.name,
|
||||
file: a.file,
|
||||
command: a.command,
|
||||
why: a.why,
|
||||
peak_dbfs: a.peak_dbfs,
|
||||
duration_s: a.duration_s,
|
||||
name_match: a.name_match,
|
||||
loop_mode: a.loop_mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The two numbers worth reading on an audio line, in the console.
|
||||
///
|
||||
/// Printed rather than left to the manifest because the failure this catches is
|
||||
/// a SILENT file: the right duration, the right channel count, the right size,
|
||||
/// and nothing in it. `-inf dB` on stdout is the one form of that failure a
|
||||
/// person notices without being told to look.
|
||||
fn describe(a: &audio::Exported) -> String {
|
||||
let peak = match a.peak_dbfs {
|
||||
Some(p) => format!("peak {p:.1} dBFS"),
|
||||
None => "peak unmeasured".into(),
|
||||
};
|
||||
match a.duration_s {
|
||||
Some(d) => format!("{d:.3} s, {peak}"),
|
||||
None => peak,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user