Files
Sylpheed/docs/port/AUDIO-VERIFICATION.md
sim ac0ad579fd chore: retire the last dead paths and names from the consolidation
Nothing here changes what a tool computes; it changes where tools look.

- tools/re-capture: 33 censuses globbed /work/sylph_extract, a path that has
  existed nowhere since /work became a clone, so they matched nothing and
  printed empty results. They now resolve the disc through a new disc.py
  from $SYLPHEED_DISC and exit loudly without it (the #44 fix, generalised).
  Nine scripts that imported siblings from the retired Reborn checkout or an
  old session scratchpad now import from their own directory. unitgroup.py
  only needs the variable when --pak is not given.
- sylpheed-xex: the loader only ever uses the XEX2 retail key. The dead
  devkit key and a doc comment claiming a devkit fallback that does not
  exist are gone; Project Sylpheed is a retail XEX2, so no XEX1 key either.
- sylpheed-viewer: real_font_rasterizes looked for /tmp/sylph_extract and so
  always skipped. It reads $SYLPHEED_DISC now, and passes against the disc.
- Comments and docs that named xenia-rs, the Reborn repository or /work/*.pe
  as places to look now name sylpheed.db, Canary's ppc_context.h and the
  flat .pe; docs/re/README.md no longer says the native Canary build does not
  run.

Historical records keep their original paths: findings that were measured
against /work/xenia-rs/sylpheed.db still say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:30:28 +02:00

403 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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]: ../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")
```
**This is implemented.** `godot --path port -- --menu … --audio=/tmp/p6.wav`
installs the effect, records for the whole run, and saves on exit — in
`_exit_tree` rather than beside each `quit()`, because there are eight of those
and the one that would get missed is an error path, i.e. exactly the run whose
audio somebody wants to look at. The run prints the driver name beside the file
it wrote.
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.
`pulseaudio-utils` is in both images, and `audio-capture` wraps it:
```bash
audio-capture run /tmp/menu.wav -- run-canary # start sink, run, record
audio-capture start # or drive it by hand
PULSE_SINK=cap godot --path port
audio-capture record /tmp/out.wav &
```
This is the route to capturing what the **game** plays — the menu move and
confirm cues behind HANDOFF Q8 — rather than what we believe it should play.
Those bindings are currently a name match against the authors' own identifiers;
a capture turns them into a measurement.
⚠️ `audio-capture run` reports the peak level and **warns when the result is
silent**, because silence is the failure that looks like success: a WAV of
exactly the right duration, full of zeroes, because the application opened a
different sink. A duration check alone would pass it.
## 5. A multichannel capture must pass a provenance check BEFORE it is analysed
`tools/port/check-capture FILE.wav` — run it first, every time.
⚠️ **This section exists because a capture of the game's own 6-channel output was
analysed at length and the file was corrupt.** It got three controls, a
drift test and a written-up negative, and every one of those was sound; none of
them could see that channels were missing, because the corruption was upstream of
everything they tested.
**PulseAudio was remapping between two mismatched channel maps, and a 6-channel
remap silently drops and duplicates.** The Decoder proved it with a control that
needs no emulator and no disc — six channels each carrying a different tone,
through the same sink and the same `parec` invocation
(`docs/re/audio-capture-channel-map-trap.md`):
| ch | played | recorded |
|---|---|---|
| 0 | 400 | 400 |
| 1 | 800 | **3200** |
| 2 | 200 | 200 |
| 3 | 1600 | **800** |
| 4 | 3200 | **800** |
| 5 | 6400 | **200** |
**Two source channels were gone entirely** and two were duplicates. Setting the
sink's `channel_map` to the guest's own (`FL,FR,FC,LFE,RL,RR`) and passing the
same map to `parec` returns all six.
### The signature is an exact duplicate pair, and only a hash finds it
Duration is right. Channel count is right. `Corked: no`. There is no error
anywhere, and the **per-channel levels look entirely reasonable** — which is the
whole difficulty. In the tool's own known-bad control, all six channels report a
peak of **18.063656 dB, identical to six decimals, while containing three
duplicate pairs.** A level check cannot see this. Hashing each channel can.
Two channels of a real surround mix are never byte-identical over tens of
seconds. On the corrupt game capture the tool reports:
```
ch2 peak -4.466272 ba497de78217c438a3e430c5ef6b951b
ch5 peak -4.466272 ba497de78217c438a3e430c5ef6b951b
🔴 ch2 and ch5 are BYTE-IDENTICAL
```
⚠️ **It is a necessary check, not a sufficient one.** Passing says the file has no
duplicated channels. It says nothing about whether the right thing was recorded —
that is what §1's correlation against a known source is for, and a capture should
survive **both** before anything is concluded from it.
### Two more conditions, learned the same way
* **Start the recorder before the process you are capturing**, so `t = 0`
precedes it and the window certainly contains the moment of interest.
* **Log what was on screen, with timestamps keyed to the recording's own clock.**
A capture that matches nothing is then diagnosable rather than ambiguous; the
corrupt one could not be told apart from "recorded the wrong phase of the boot"
by any amount of analysis at this end.
And the failure this page already warns about, in a second costume:
`run-canary` is silent **twice over**`SDL_AUDIODRIVER=dummy` *and*
`--mute=true`. Fix only the first and Canary attaches a healthy 6-channel stream
at 100 % volume, reports `Corked: no`, and emits a 19 MB WAV of zeroes.
## 7. A capture can be starved — right duration, holes punched through it
`check-capture` tests this too, and it is the second way a recording looks
perfect and carries nothing.
**A monitor sink advances at wall-clock rate and substitutes silence whenever the
producer is late.** An emulator running below real time therefore yields a file
of exactly the right duration, the right channel count, no duplicated channels —
chopped into fragments with holes between them, thousands of times over.
Measured independently on the capture that prompted this (the Decoder's numbers
on the untruncated original in brackets):
| | |
|---|---|
| frames silent on **all six** channels | **35.6 %** [39.3 %] |
| alternating runs | **10 482** [10 595] |
| median burst / gap | **13.5 ms / 3.9 ms** [13.6 / 3.9] |
| period | **17.4 ms → 57 Hz** [≈17.5 ms → 57 Hz] |
⚠️ **This destroys envelope correlation by construction.** What dominates the
envelope of such a file is the dropout schedule, not the content — so §6's method
was working correctly on a file that could not carry the signal, and the negative
it produced said nothing about the game.
### Two thresholds I invented were wrong, and the controls caught both
1. **Counting exact-zero frames.** Real audio crosses zero constantly, so a clean
voice track scored **5 947 "gaps" of median 0.0 ms** and was called starved. A
gap is a **run**, not a sample: only runs of ≥ 1 ms count.
2. **Gap count and median length.** A genuine music-and-effects bed has **454
gaps at a median of 1.4 ms** — quiet 16-bit passages really are zero for
milliseconds — so neither statistic separates it from a starved file.
3. 🔴 **The gap RATE alone.** This one shipped, and the Decoder found it: raising
the client buffer keeps cutting the rate while total silence **bottoms out and
then doubles**, because an over-large buffer starves in a few enormous holes
instead of many small ones. Its `PULSE_LATENCY_MSEC=500` capture scores
**1.3 gaps/s — better than a genuine music bed at 3.3 — while being 50 %
silence**, and a 20/s bar passed it.
**It takes two numbers, because either one alone is blind to the failure next
door** — the same shape as a level table that cannot see a duplicated channel.
Reproduced on a file held here (`bigholes`: a real bed with 350 ms holes punched
into it) so the regime is controlled rather than quoted:
| control | all-channel silence | gaps/s | verdict |
|---|---|---|---|
| real music+SFX bed | 1.1 % | 3.3 | **PASS** |
| voice track, mono, real pauses | 53.2 % | 0.3 | **PASS** |
| bed with 350 ms holes | **46.3 %** | 3.2 | **FAIL** |
| the starved capture | **35.6 %** | 30.9 | **FAIL** |
Rate alone cannot separate rows 2 and 3; silence alone cannot separate rows 1 and
3. **The pair does:** fail when ≥ 10 % of the file is silent on every channel
*and* there is at least 1 gap per second. Real audio is either mostly not silent,
or silent in a few long stretches — not both at once.
### A format it cannot read is refused, not guessed at
Everything in the starvation check assumes 16-bit signed. An ALSA `type file` tee
writes **float32** (`SND_PCM_FORMAT_FLOAT_LE`), and read as s16 that produces a
*plausible-looking* file — the Decoder measured one, and its only tell was
per-channel peaks alternating **exactly**, which is the two halves of each float
landing in alternate channels.
So an unreadable format ends the run at **`PARTIAL`** (exit 2), not `PASS`:
channels were checked, starvation was not, and the tool says which. A checker
that claims a check it skipped is the shape of every failure this file documents.
⚠️ **`WAVE_FORMAT_EXTENSIBLE` (tag `0xFFFE`) is accepted at 16 bits**, and the
first version of the guard was not — it rejected one of this tool's own controls,
a file `ffprobe` correctly calls `pcm_s16le`. **A format guard that refuses a
legitimate capture is the same defect as one that mis-reads an illegitimate one**,
pointing the other way. The check turns on `wBitsPerSample`, which is what
actually decides the sample layout; a float tee is 32-bit and is still caught.
### The control sweep, which is the tool's real specification
**Run it: `tools/port/check-capture-controls`.** 🔴 Until 2026-08-30 this table was prose — the specification existed and nothing executed it, so a regression in `check-capture` or a drifting threshold would have gone unremarked in a tool whose own history is *two invented thresholds that were both wrong and were caught only by controls*. This document states the principle it was breaking: **"a control that does not execute is not a control."**
⚠️ The verdicts below are **compressed**. `check-capture` emits two — one for channel provenance, one for starvation — and the sweep asserts the pair, because the voice control is `PASS` on channels and `UNJUDGED` on starvation *by design* and a single word cannot say that. A starved file **short-circuits** before the channel check, which the sweep records as `n/a` rather than as a failure: *the check did not run* and *the check failed* are different facts.
⚠️ The **starved capture cannot be rebuilt** — that artifact was transient and is gone. The sweep reports it `MISSING` rather than omitting it, and deliberately does not synthesise one from the statistics published above: a control fitted to the answer it must give is not a control either.
| file | verdict |
|---|---|
| real music+SFX bed | `PASS` |
| voice track, mono, 53 % real pauses | `PASS` |
| six distinct tones (PCM and extensible) | `PASS` |
| bed with 350 ms holes punched in | **`FAIL`** |
| the starved capture | **`FAIL`** |
| the same tones as float32 | **`PARTIAL`** |
### ⚠️ The regime this tool cannot judge, and says so
**High silence with very few gaps is what a real voice track looks like (53.2 %
in 0.3 gaps/s) and also what an over-buffered capture looks like.** No statistic
here separates them. The tool prints `UNJUDGED` and tells you to check the file
against a known source rather than passing it silently — because inventing a bar
for a regime with no control in it is how the two bars above came to be wrong.
⚠️ **A control that does not execute is not a control.** An earlier version
returned immediately for a single-channel file, so the mono voice track — one of
the four controls — was never actually run through the check it was meant to
control. Mono now skips only the duplicate test.
### 🟡 The monitor-sink route may be fixable after all — retry before rebuilding
An earlier version of this section said the route *"cannot be fixed by
configuration"*. **Withdrawn.** That inferred from the holes that the guest runs
below real time, without testing the alternative: **the client buffer is simply
tiny.** Xenia asks SDL for 256 samples — **5.33 ms** at 6 ch — against a stock
`daemon.conf` with no fragment tuning.
| client buffer | silence | gaps/s |
|---|---|---|
| Xenia default (~5.3 ms) | 39.3 % | 30.5 |
| `PULSE_LATENCY_MSEC=200` | **15.6 %** | 3.5 |
| `PULSE_LATENCY_MSEC=500` | 50.1 % | 1.3 |
⚠️ Not clean, and not like-for-like — 88 s against 347 s, and the short run covers
the splash logos where silence is real. But **the capture route deserves a retry
at ~200 ms before anyone spends a session on a Canary rebuild.**
### The tap, if configuration is not enough
`parec` reads a monitor that advances at wall-clock rate and substitutes silence,
so **every moment the emulator runs below real time is a hole**, and the timebase
is warped non-uniformly — deleting the silences compresses time unevenly rather
than repairing it. The route that would work is an **internal tap at
`SDLAudioDriver::SubmitFrame`**, which sees every frame the guest produces in
guest order with no wall clock in the loop.
⚠️ That needs a Canary rebuild, and the Decoder has costed it: `build-canary`
targets a source root that does not exist in that container, the warm build tree
is configured against the same missing path, so any change is a full reconfigure
plus a full compile on a box with ~700 MB free and a history of parallel builds
OOM-killing the host. **A whole session for one probe** — the human's call, not
an agent's.
### And a header that never got patched
A streaming writer leaves `data` declaring **0 bytes**. `check-capture` says so
and tells you the duration is unverified — which is not pedantry: the file shared
here was **copied while it was still being written**, and the provenance claim
that came with it was wrong about both its length and what it contained.
## 6. Finding one component inside a mix — and why §1's method cannot
🔴 **This section begins with a retraction.** Two captures of the game's own
output were analysed with sliding envelope cross-correlation and declared not to
contain the intro's audio. **The instrument was never controlled for the actual
task**, and when it finally was, it failed:
> Can it find the movie's bed inside a synthetic mix of that bed plus the three
> voice streams? **r = 0.415** — below the `r > 0.8` bar those negatives were
> judged against.
The first negative happened to be right (the file was independently proved
corrupt by a tone control). **It was right by luck, and the reasoning behind it
was not supported.** A filter that fails its own known-positive is dead, not
tuneable.
### What was wrong: the threshold, not the idea
`r > 0.8` was calibrated on **clean-against-clean** comparisons, where it is
correct — a transcode against its source scores 1.000. A *component inside a
mix* can never score that, because everything else in the mix is uncorrelated
noise from the component's point of view. Judging one task by the other's bar
guarantees a false negative.
**Judge on the LAG and the MARGIN instead.** A real match lands at the *right*
lag with a clear gap to the runner-up; a false one is a plateau. And **band-limit
first**, so the component you are hunting dominates what you measure.
### The calibration, on a known-present and a known-absent pair
Both bands, both directions, envelope at 0.1 s, minimum 60 s overlap:
| hunting | band | against | *r* | lag | **margin** |
|---|---|---|---|---|---|
| the movie bed | 40180 Hz | mix containing it | 0.663 | **0.0 s** ✓ | **+0.111** |
| the movie bed | 40180 Hz | voice-only mix | 0.262 | 31.9 s ✗ | +0.005 |
| voice stream 2 | 3003000 Hz | mix containing it | 0.810 | **0.0 s** ✓ | **+0.248** |
| voice stream 2 | 3003000 Hz | the bed alone | 0.358 | 58.4 s ✗ | +0.005 |
**A 2050× separation in the margin, and the lag is right or absurd.** That is a
decision rule set by controls rather than by tuning until the data agreed —
which is the distinction that matters, and the one the first version of this
method skipped.
⚠️ **Reach.** The known-positive is a *synthetic* mix at equal gains. A real game
mix weights its components differently, so this bounds the method rather than
modelling the real case exactly. It is enough to separate present from absent; it
is not a level measurement.
## 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.
## 4. What the exporter checks, so nobody has to remember to
`sylpheed-export` measures **peak level and duration** of every audio file it
writes and records both in `manifest.json`; `sylpheed-export check` refuses a
tree whose peak is ≤ 90 dBFS (silent) or ≥ 0 dBFS (clipping).
Those are content checks in a format validator on purpose. Silence is the failure
this page opens by naming — right duration, right channel count, right size, full
of zeroes — and every structural check passes it. Clipping is the other one, and
the BGM can produce it, because a music bank is two stems summed at unity gain
(HANDOFF Q10).
⚠️ Neither says the audio is the **right** audio. `docs/port/BLOCKED.md` says
which bindings are measured and which are still authored, and no measurement on
this page can move a row there.