From 7d494359d1ecc296922fe55b5a60567704408a82 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Sat, 29 Aug 2026 09:04:47 +0000 Subject: [PATCH] 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. --- crates/sylpheed-export/src/video.rs | 75 +++++++++++++++++++++---- tools/verify-video-audio | 86 +++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 10 deletions(-) create mode 100755 tools/verify-video-audio diff --git a/crates/sylpheed-export/src/video.rs b/crates/sylpheed-export/src/video.rs index fa580520..963eef81 100644 --- a/crates/sylpheed-export/src/video.rs +++ b/crates/sylpheed-export/src/video.rs @@ -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 { - [ +/// 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 { + 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 { + let mut v: Vec = [ "-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/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 } }'