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 } }'