video: state the 5.1 downmix instead of inheriting it, and write atomically

A real defect in the P4 output, found by the human on ADV.wmv and widened by the
RE agent to the whole disc: the disc ships 28 movies in 5.1 WMA Pro (every
cutscene, INCLUDING both movies this port needs) and 69 already in stereo. A
bare `-ac 2` therefore does two different things and records neither -- stereo
passes through, and 5.1 is folded by ffmpeg's DEFAULT matrix. How loudly
centre-channel dialogue sits against the music is a content decision, and it was
being made by accident and could move under an ffmpeg upgrade.

Now stated: ITU-R BS.775, LFE dropped, normalised by 1/(1+2*sqrt(1/2)) = 0.4142.
It appears in the recorded command, so the manifest determines the output.

MEASURED rather than chosen by taste, and the measurement is the interesting
part: the explicit matrix and ffmpeg's inherited default differ by a residual of
-91 dB -- about one LSB at 16-bit -- with peak and mean agreeing to 0.1 dB. So
ffmpeg's default IS this matrix, and the audio does not change; what changes is
that the manifest now says which matrix. The UNnormalised textbook form was
measured too and clips at 0.0 dBFS, which is why the scaling is there.

The filter is applied only to 6-channel sources, probed per file with ffprobe,
so a stereo source is never run through a matrix referencing channels it lacks.

Also: encode to a temp name and rename on success. ffprobe read a mid-write
.ogv as 33 s against a 137 s source -- no error, no warning, the exact shape of
catastrophic truncation. The filesystem is shared with the RE agent, so that is
a race, not an edge case, and a half-written file must never be visible under
its final name.

tools/verify-video-audio answers the second of the three questions
docs/AUDIO-VERIFICATION.md separates: does GODOT route the audio. An
AudioEffectRecord on the Master bus writes Godot's own mixed output to a WAV
from a headless run, so "no sound card" was never the obstacle I claimed. It
deliberately checks non-silence and level only -- a difference-signal RMS
against the source is inconclusive without cross-correlation alignment and an
agreed downmix, and would produce a confident wrong number.
This commit is contained in:
Sylpheed port agent
2026-08-29 09:04:47 +00:00
parent 753d62a08f
commit 7d494359d1
2 changed files with 151 additions and 10 deletions

View File

@@ -45,20 +45,62 @@ pub const MOVIES: &[Movie] = &[
/// be too poor and asked for the FFmpeg-GDExtension fallback to be *proposed*
/// if so. It is not: **no runtime dependency is needed, and none is requested.**
///
/// `-ac 2` because the source is 6-channel WMA Pro and Godot's Theora playback
/// is not a surround path. Downmixing is a decision, so it is in the recorded
/// command where a modder can see and change it.
fn args(src: &Path, out: &Path) -> Vec<String> {
[
/// The stereo downmix, **stated explicitly rather than inherited**.
///
/// The disc ships movies in two audio profiles: 28 files are 5.1 WMA Pro (every
/// cutscene, including both movies this port needs) and 69 are already stereo.
/// A bare `-ac 2` therefore does two different things and records neither — the
/// stereo files pass through, and the 5.1 files are folded by **ffmpeg's default
/// matrix**. How loudly centre-channel dialogue sits against the music is a
/// CONTENT decision, and leaving it to a default means it is made by accident
/// and can move under an ffmpeg upgrade.
///
/// So the matrix is written out: **ITU-R BS.775, LFE dropped**, normalised by
/// `1/(1 + √½ + √½) = 0.4142` so the sum of coefficients cannot clip.
///
/// This does not change the audio. Measured against the inherited default over a
/// 25 s stretch, the residual is **91 dB** — roughly one LSB at 16-bit, i.e.
/// coefficient rounding — and peak and mean levels agree to 0.1 dB. ffmpeg's
/// default *is* this matrix; the point is that the manifest now says so.
///
/// The unnormalised form was measured too and **clips**: peak 0.0 dBFS. That is
/// why the normalisation is here rather than the textbook coefficients.
const DOWNMIX_51: &str = "pan=stereo|FL=0.4142*FL+0.2929*FC+0.2929*BL |FR=0.4142*FR+0.2929*FC+0.2929*BR";
/// How many audio channels the source declares.
fn channels(src: &Path) -> Result<u32> {
let out = Command::new("ffprobe")
.args([
"-v", "error", "-select_streams", "a:0",
"-show_entries", "stream=channels", "-of", "csv=p=0",
])
.arg(src)
.output()
.context("run ffprobe -- is it on PATH?")?;
Ok(String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(2))
}
fn args(src: &Path, out: &Path, channels: u32) -> Vec<String> {
let mut v: Vec<String> = [
"-hide_banner", "-loglevel", "error", "-y",
"-i", &src.display().to_string(),
"-c:v", "libtheora", "-q:v", "8",
"-c:a", "libvorbis", "-q:a", "5", "-ac", "2",
&out.display().to_string(),
"-c:a", "libvorbis", "-q:a", "5",
]
.iter()
.map(|s| s.to_string())
.collect()
.collect();
// Only 5.1 sources are folded. A source that is already stereo is passed
// through untouched rather than run through a matrix that would silently
// reference channels it does not have.
if channels == 6 {
v.push("-af".into());
v.push(DOWNMIX_51.into());
}
v.push("-ac".into());
v.push("2".into());
v.push(out.display().to_string());
v
}
pub struct Transcoded {
@@ -85,21 +127,34 @@ pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result<Option<Transcoded
let ogv = dir.join(format!("{}.ogv", m.stem));
let stamp = dir.join(format!("{}.cmd", m.stem));
let argv = args(&src, &ogv);
let ch = channels(&src)?;
let argv = args(&src, &ogv, ch);
let command = format!("ffmpeg {}", argv.join(" "));
let size = std::fs::metadata(&src)?.len();
let want = format!("{command}\nsource-bytes: {size}\n");
let want = format!("{command}\nsource-bytes: {size}\nsource-channels: {ch}\n");
let fresh = ogv.exists()
&& std::fs::read_to_string(&stamp).map(|s| s == want).unwrap_or(false);
if !fresh {
// Encode to a temp name and rename on success. A reader that catches
// this mid-write sees no file at all rather than a valid-looking one
// with a wrong duration -- ffprobe reported 33 s against a 137 s source
// during one such race, with no error, and it looked exactly like
// catastrophic truncation. The filesystem is shared with another agent,
// so this is a race and not an edge case.
let partial = dir.join(format!(".{}.partial.ogv", m.stem));
let mut argv = argv.clone();
let last = argv.len() - 1;
argv[last] = partial.display().to_string();
let status = Command::new("ffmpeg")
.args(&argv)
.status()
.context("run ffmpeg -- is it on PATH?")?;
if !status.success() {
let _ = std::fs::remove_file(&partial);
bail!("ffmpeg failed on {}", m.src);
}
std::fs::rename(&partial, &ogv)?;
std::fs::write(&stamp, &want)?;
}
Ok(Some(Transcoded {

86
tools/verify-video-audio Executable file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Prove Godot actually emits a transcoded movie's audio -- with no audio device.
#
# tools/verify-video-audio ADV
#
# This container has no sound card and Godot falls back to the dummy driver, so
# "does it play" looked unanswerable from here. It is not: an AudioEffectRecord
# on the Master bus makes Godot write its own mixed output to a WAV from inside
# a headless run. That is Godot rendering audio to a file instead of a device --
# no new dependency, no image rebuild, and it tests the real playback path
# rather than the file the encoder produced.
#
# What this checks is that GODOT EMITS NON-SILENCE from the movie. It is
# deliberately NOT a fidelity comparison against the source: a difference-signal
# RMS between a transcode and its source is inconclusive without cross-
# correlation alignment and an agreed downmix -- a one-sample offset makes the
# residual nearly as loud as the signal. Level and non-silence are what this
# claims.
set -euo pipefail
cd "${PROJECT_DIR:-/work}"
name="${1:-ADV}"
OUT="${OUT:-${TMPDIR:-/tmp}/verify-video-audio}"
export DISPLAY="${DISPLAY:-:97}"
mkdir -p "$OUT"
[ -d port/.godot ] || godot --headless --path port --import >/dev/null 2>&1
wav="$OUT/$name.godot.wav"
rm -f "$wav"
cat > "$OUT/probe.gd" <<'GD'
extends SceneTree
func _init() -> void:
var args := {}
for a in OS.get_cmdline_user_args():
if a.begins_with("--") and a.contains("="):
var p := a.substr(2).split("=", true, 1)
args[p[0]] = p[1]
var tree_ := ExportTree.locate()
if tree_.root == "":
push_error(tree_.error); quit(2); return
var v: Dictionary = tree_.video(args.get("video", "ADV"))
if v.is_empty():
push_error(tree_.error); quit(2); return
# Record the MASTER bus: whatever Godot mixes, including the dummy driver's
# output. This is the real playback path, not the encoded file.
var rec := AudioEffectRecord.new()
AudioServer.add_bus_effect(0, rec)
var stream := VideoStreamTheora.new()
stream.file = v["path"]
var p := VideoStreamPlayer.new()
p.stream = stream
get_root().add_child(p)
await process_frame
rec.set_recording_active(true)
p.play()
var seconds := float(args.get("seconds", "6"))
var t := 0.0
while t < seconds and p.is_playing():
await process_frame
t += get_root().get_process_delta_time()
rec.set_recording_active(false)
var clip := rec.get_recording()
if clip == null:
push_error("no recording came back from the Master bus"); quit(3); return
clip.save_to_wav(args.get("out", "/tmp/godot-audio.wav"))
print("recorded %.2f s, %d Hz, stereo=%s -> %s" % [
t, clip.mix_rate, clip.stereo, args.get("out", "")])
quit(0)
GD
godot --path port --resolution 320x180 --script "$OUT/probe.gd" -- \
"--video=$name" "--out=$wav" "--seconds=${SECONDS_TO_RECORD:-6}" 2>&1 \
| grep -viE "ALSA|Vulkan|V-Sync|OpenGL|audio driver|^ *at: |Condition|^$" || true
[ -s "$wav" ] || { echo "verify-video-audio: Godot wrote no WAV" >&2; exit 1; }
echo "--- what Godot emitted ---"
ffmpeg -hide_banner -i "$wav" -af volumedetect -f null - 2>&1 \
| grep -oE "(max_volume|mean_volume): [-0-9.]+ dB" | sed 's/^/ /'
mean=$(ffmpeg -hide_banner -i "$wav" -af volumedetect -f null - 2>&1 \
| grep -oE "mean_volume: [-0-9.]+" | grep -oE -- "-?[0-9.]+")
# Digital silence reports around -91 dB at 16-bit. Anything near that is nothing.
awk -v m="$mean" 'BEGIN{ if (m < -80) { print " VERDICT: silence -- Godot is not emitting this movie\047s audio"; exit 1 }
else { printf " VERDICT: audio present (mean %.1f dB)\n", m } }'