port: pin by tag, and how to verify audio with no sound card
**The pin.** Pin a TAG, never a bare sha. A sha reachable only from an auto/* branch is orphaned when that branch is deleted or -- worse -- squash-merged, because squash creates new commits: main looks like it contains the work while the pin becomes unreachable and this project stops building for a fresh checkout, silently, at their build. formats-pin-2026-08-29 exists for the current state. Also says plainly why NOT to float to a branch, which was the tempting fix: Cargo resolves a git dependency once and writes the sha into Cargo.lock, so floating gives staleness you cannot see instead of staleness you can read. push-work now pushes --follow-tags so annotated tags travel with the branch. **Audio.** docs/AUDIO-VERIFICATION.md separates three questions that were being asked as one: is the transcode faithful (no engine, no device -- a file-vs-file difference measurement), does Godot route it (AudioEffectRecord on the Master bus writes a WAV from a headless run), and what does the GAME play (a PulseAudio null sink, which needs a rebuild). It leads with the three ways the fidelity measurement lies, because all three were hit on the first attempt and each produces a confident wrong number rather than an error: unaligned subtraction, mismatched channel layouts, and probing a file another process is still writing. The 5.1 disc fact deliberately is NOT copied here -- it lives in the RE corpus at docs/re/structures/movie-audio-channels.md and is linked, so there is one copy to keep true rather than two that drift. Same reason HANDOFF is a summary with links. The downmix itself stays flagged as an unmade decision, not quietly resolved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
# `sylph-agent`). They are never printed, never logged, and never passed on a
|
||||
# command line.
|
||||
#
|
||||
# push-work push the current branch
|
||||
# push-work push the current branch, and any annotated tags on it
|
||||
# push-work --dry-run say what it would do
|
||||
set -euo pipefail
|
||||
|
||||
@@ -72,5 +72,5 @@ fi
|
||||
# --force-with-lease is deliberately NOT offered. If this is rejected as
|
||||
# non-fast-forward, someone else moved the branch: fetch and merge, do not
|
||||
# overwrite.
|
||||
git -c "credential.helper=$CRED_HELPER" push --set-upstream origin "$branch"
|
||||
git -c "credential.helper=$CRED_HELPER" push --follow-tags --set-upstream origin "$branch"
|
||||
echo "push-work: pushed $branch"
|
||||
|
||||
110
docs/AUDIO-VERIFICATION.md
Normal file
110
docs/AUDIO-VERIFICATION.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Verifying audio without an audio device
|
||||
|
||||
Neither container has a sound card, so "does it actually play?" cannot be
|
||||
answered by listening. It can be answered by measurement, and the two things
|
||||
usually meant by that question need different measurements.
|
||||
|
||||
**Separate them before reaching for a tool:**
|
||||
|
||||
| question | needs Godot? | needs a device? |
|
||||
|---|---|---|
|
||||
| Is the transcoded file faithful to the source? | no | no |
|
||||
| Does Godot actually route it to an output? | yes | no |
|
||||
| What does the *game* play on a menu move? | no (Canary) | a virtual one |
|
||||
|
||||
## 1. Transcode fidelity — file against file
|
||||
|
||||
This is the question P4 actually raised, and it needs neither an engine nor a
|
||||
device. Decode both, subtract, and measure what is left.
|
||||
|
||||
```bash
|
||||
# Source, for a reference level
|
||||
ffmpeg -hide_banner -t 25 -i ADV.wmv \
|
||||
-af "aformat=channel_layouts=stereo,astats=measure_perchannel=none" -f null - 2>&1 \
|
||||
| grep "RMS level"
|
||||
|
||||
# The difference signal: source minus transcode
|
||||
ffmpeg -hide_banner -t 25 -i ADV.wmv -t 25 -i ADV.ogv -filter_complex \
|
||||
"[0:a]aformat=channel_layouts=stereo[a];\
|
||||
[1:a]aformat=channel_layouts=stereo,volume=-1[b];\
|
||||
[a][b]amix=inputs=2:normalize=0,astats=measure_perchannel=none" -f null - 2>&1 \
|
||||
| grep "RMS level"
|
||||
```
|
||||
|
||||
A faithful transcode puts the difference **40 dB or more below** the source.
|
||||
|
||||
### Three ways this measurement lies
|
||||
|
||||
Run it wrong and it reports a disaster that is not there. All three of these
|
||||
were hit on the first attempt:
|
||||
|
||||
* **Alignment.** A one-sample offset makes the difference nearly as loud as the
|
||||
source. Cross-correlate and compensate *before* subtracting, or the number is
|
||||
meaningless. A first run gave source −25.3 dB against difference −34.2 dB —
|
||||
only 9 dB down, which looks catastrophic and proves nothing.
|
||||
* **Channel layout.** The source and the transcode do not have the same channel
|
||||
count. You are not comparing like with like unless both sides are downmixed
|
||||
the same way, and `astats` will give you a confident number regardless. See
|
||||
[`movie-audio-channels`][mac] for which profile a given movie is in — that is
|
||||
a disc fact and lives in the RE corpus, not here.
|
||||
* **A file still being written.** `ffprobe` reported the `.ogv` as 33 s against
|
||||
the source's 137 s — apparent catastrophic truncation, actually a transcode in
|
||||
progress. Check `mtime` and packet count before believing a duration, and
|
||||
write to a temp name and rename on completion so a reader cannot see a partial
|
||||
file at all.
|
||||
|
||||
⚠️ **The downmix is an unrecorded decision, and it is not ours to make quietly.**
|
||||
Nothing in the manifest says a fold happened or on what weighting; it is whatever
|
||||
ffmpeg defaulted to, and that default can change between versions. Centre-channel
|
||||
dialogue folds into L/R, so this changes how speech sits against music — an
|
||||
aesthetic judgement, not a container detail. Pin it explicitly and record it,
|
||||
exactly as MISSION §6 requires of the transcode command itself.
|
||||
|
||||
[mac]: https://git.mc02.dev/fabi/Syplheed-Reborn/src/branch/main/docs/re/structures/movie-audio-channels.md
|
||||
|
||||
## 2. Engine routing — Godot writes a WAV instead of a device
|
||||
|
||||
Godot does not need a sound card to produce audio you can inspect. Put an
|
||||
`AudioEffectRecord` on the **Master** bus and it captures the mixed output from
|
||||
inside a headless run:
|
||||
|
||||
```gdscript
|
||||
var bus := AudioServer.get_bus_index("Master")
|
||||
var rec := AudioEffectRecord.new()
|
||||
AudioServer.add_bus_effect(bus, rec)
|
||||
rec.set_recording_active(true)
|
||||
# ... play the scene ...
|
||||
rec.set_recording_active(false)
|
||||
rec.get_recording().save_to_wav("user://master.wav")
|
||||
```
|
||||
|
||||
Then feed that WAV through §1 against the source. That closes the loop: it
|
||||
proves the asset is right **and** that the engine reached it, which no amount of
|
||||
file comparison can show on its own.
|
||||
|
||||
Confirm the dummy driver is what is actually in use rather than assuming it —
|
||||
`AudioServer.get_driver_name()` — and say so in the write-up, because "recorded
|
||||
under a dummy driver" is a weaker claim than "heard", and the difference matters.
|
||||
|
||||
## 3. A virtual device, when something insists on a real one
|
||||
|
||||
For anything that opens a device rather than a bus — the emulator, most
|
||||
obviously — a PulseAudio **null sink** is a real device that records to a file:
|
||||
|
||||
```bash
|
||||
pactl load-module module-null-sink sink_name=cap sink_properties=device.description=cap
|
||||
PULSE_SINK=cap <the application>
|
||||
parec -d cap.monitor --file-format=wav /tmp/captured.wav
|
||||
```
|
||||
|
||||
This is the route to capturing what the *game* plays — the menu move and confirm
|
||||
cues behind HANDOFF Q8 — rather than what we think it should play. It needs
|
||||
`pulseaudio-utils` in the image, so it is a rebuild, not something to reach for
|
||||
mid-iteration.
|
||||
|
||||
## What none of this establishes
|
||||
|
||||
That it *sounds right*. Every method here shows correspondence to a source, not
|
||||
that the source is the audio the game plays at that moment, and not that levels
|
||||
are sane in a mix. A ten-second human listen still answers something no
|
||||
measurement above does — so when a result rests on one of these, say which one.
|
||||
@@ -43,10 +43,21 @@ XMA, no WMV. If Godot cannot read something, the exporter's job is to emit it
|
||||
differently — not to bridge the gap at runtime.
|
||||
|
||||
* **No GDExtension. No Rust in `port/`.**
|
||||
* The decoders come from `sylpheed-formats`, **pinned by revision**. Do not vendor
|
||||
them, do not reimplement them, and do not float the pin — a decoder change
|
||||
landing mid-milestone is exactly the confusion this pin prevents.
|
||||
* Bump the pin deliberately, as its own commit, saying what you wanted from it.
|
||||
* The decoders come from `sylpheed-formats`, **pinned by TAG**:
|
||||
`sylpheed-formats = { git = "...", tag = "formats-pin-2026-08-29" }`.
|
||||
|
||||
Pin a tag, never a bare sha. A sha reachable only from an `auto/*` branch is
|
||||
orphaned when that branch is deleted or — worse — **squash-merged**, because
|
||||
squash creates *new* commits: `main` looks like it contains the work while the
|
||||
pin becomes unreachable and this project stops building for a fresh checkout.
|
||||
A tag is a permanent ref, it says what it is in `Cargo.toml`, and it fails
|
||||
loudly at *fetch* rather than silently at build.
|
||||
* **Do not float the pin** to a branch. It would not do what it sounds like:
|
||||
Cargo resolves a git dependency once and writes the sha into `Cargo.lock`, so
|
||||
floating gives you staleness you cannot see instead of staleness you can read.
|
||||
* Bump deliberately, as its own commit, saying what you wanted from the new
|
||||
state. The RE agent tags when it lands something you need and tells you over
|
||||
the message channel — that is how you stay current without floating.
|
||||
|
||||
**In particular, do not reimplement media assembly.** `sylpheed_formats::media`
|
||||
already handles the cases where one playable thing is not one archive entry: a
|
||||
|
||||
Reference in New Issue
Block a user