Skip to content

Commit 321ae61

Browse files
test: validate safe server-side audio correction
1 parent e018b68 commit 321ae61

15 files changed

Lines changed: 1824 additions & 1 deletion

.github/workflows/docker-build-media-server.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,43 @@ on:
1313
- main
1414
paths:
1515
- "apps/media-server/**"
16+
- "scripts/benchmark-instant-audio.py"
17+
- "scripts/benchmark-audio-intelligibility.py"
18+
- "scripts/test-audio-intelligibility.py"
1619
- ".github/workflows/docker-build-media-server.yml"
1720
pull_request:
1821
paths:
1922
- "apps/media-server/**"
23+
- "scripts/benchmark-instant-audio.py"
24+
- "scripts/benchmark-audio-intelligibility.py"
25+
- "scripts/test-audio-intelligibility.py"
2026
- ".github/workflows/docker-build-media-server.yml"
2127

28+
permissions: {}
29+
2230
concurrency:
2331
group: media-server-${{ github.head_ref || github.ref_name }}-${{ inputs.tag || 'latest' }}
2432
cancel-in-progress: true
2533

2634
jobs:
35+
audio-metrics:
36+
name: Audio intelligibility alignment
37+
runs-on: ubuntu-24.04
38+
timeout-minutes: 5
39+
permissions:
40+
contents: read
41+
steps:
42+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
43+
with:
44+
persist-credentials: false
45+
- name: Verify aligned audio metrics
46+
env:
47+
PYTHONDONTWRITEBYTECODE: "1"
48+
run: |
49+
python3 -m venv "$RUNNER_TEMP/audio-metrics"
50+
"$RUNNER_TEMP/audio-metrics/bin/python" -m pip install --disable-pip-version-check --no-input --only-binary=:all: numpy==2.4.1 scipy==1.18.1 pystoi==0.4.1
51+
"$RUNNER_TEMP/audio-metrics/bin/python" scripts/test-audio-intelligibility.py
52+
2753
build:
2854
name: Build Docker Image (${{ matrix.platform }})
2955
runs-on: ${{ matrix.runner }}
@@ -77,6 +103,11 @@ jobs:
77103
env:
78104
MEDIA_IMAGE: ${{ github.event_name == 'pull_request' && 'cap-media-server:verification' || format('ghcr.io/{0}/cap-media-server@{1}', env.REPOSITORY_OWNER, steps.build.outputs.digest) }}
79105
run: |
106+
docker run --rm --network none --entrypoint bun "$MEDIA_IMAGE" test \
107+
src/__tests__/lib/audio-quality-policy.test.ts \
108+
src/__tests__/lib/audio-quality.integration.test.ts \
109+
src/__tests__/lib/audio-quality-formats.integration.test.ts \
110+
src/__tests__/lib/audio-quality-benchmark.test.ts
80111
docker run --rm --network none --entrypoint bun "$MEDIA_IMAGE" test \
81112
src/__tests__/lib/recording-verification.integration.test.ts \
82113
src/__tests__/lib/job-manager.test.ts

apps/media-server/Dockerfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ RUN bun install --frozen-lockfile --production
1111

1212
COPY apps/media-server/src ./src
1313

14-
RUN bun test src/__tests__/lib/drive-resumable-upload.test.ts src/__tests__/lib/storage-upload.test.ts src/__tests__/lib/container-memory.test.ts \
14+
RUN bun test src/__tests__/lib/audio-quality-policy.test.ts src/__tests__/lib/audio-quality.integration.test.ts \
15+
&& bun test src/__tests__/lib/audio-quality-formats.integration.test.ts src/__tests__/lib/audio-quality-benchmark.test.ts \
16+
&& bun test src/__tests__/lib/drive-resumable-upload.test.ts src/__tests__/lib/storage-upload.test.ts src/__tests__/lib/container-memory.test.ts \
1517
&& bun test src/__tests__/lib/media-size.test.ts src/__tests__/lib/media-probe.integration.test.ts \
1618
&& bun test src/__tests__/lib/recording-verification.integration.test.ts src/__tests__/lib/job-manager.test.ts \
1719
&& bun test src/__tests__/lib/media-transfer.test.ts \
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
{
2+
"baseline": {
3+
"count": 60,
4+
"hours": 4.463996666666667,
5+
"medianLufs": -29.205,
6+
"belowMinus24": 40,
7+
"peakAboveZero": 9,
8+
"alreadyLoud": 9
9+
},
10+
"voice": {
11+
"count": 60,
12+
"passing": 42,
13+
"rejected": 1,
14+
"unchanged": 17,
15+
"errors": [],
16+
"skipReasons": {
17+
"unsafe-levels": 12,
18+
"already-loud": 5
19+
},
20+
"medianInputLufs": -31.744999999999997,
21+
"medianOutputLufs": -16.84,
22+
"medianGain": 14.495,
23+
"maxTruePeak": -1.34,
24+
"maxDurationDeltaMs": 20.999999999958163,
25+
"sampleCountsExact": true,
26+
"medianRealtimeFactor": 0.08116900157594112
27+
},
28+
"levels": {
29+
"count": 60,
30+
"passing": 40,
31+
"rejected": 0,
32+
"unchanged": 20,
33+
"errors": [],
34+
"skipReasons": {
35+
"unsafe-levels": 12,
36+
"already-loud": 5,
37+
"insufficient-headroom": 3
38+
},
39+
"medianInputLufs": -32.07,
40+
"medianOutputLufs": -23.08,
41+
"medianGain": 10.64,
42+
"maxTruePeak": -1.95,
43+
"maxDurationDeltaMs": 20.999999999958163,
44+
"sampleCountsExact": true,
45+
"medianRealtimeFactor": 0.09984551719406805
46+
},
47+
"holdoutVoice": {
48+
"count": 21,
49+
"passing": 15,
50+
"rejected": 1,
51+
"unchanged": 5,
52+
"errors": [],
53+
"skipReasons": {
54+
"unsafe-levels": 2,
55+
"already-loud": 3
56+
},
57+
"medianInputLufs": -30.92,
58+
"medianOutputLufs": -16.78,
59+
"medianGain": 13.339999999999998,
60+
"maxTruePeak": -1.34,
61+
"maxDurationDeltaMs": 20.999999999958163,
62+
"sampleCountsExact": true,
63+
"medianRealtimeFactor": 0.08261003124306479
64+
},
65+
"codeHash": "40ef7500ef8643decd898509879bfe0293ced1f5d124f8a93535f9080f1bf609",
66+
"scope": "Offline public/transcribed cohort; passing technical gates does not establish perceptual quality or production eligibility.",
67+
"productionEnabled": false,
68+
"reviewValidation": {
69+
"workerCodeHash": "d1dbc4b3be3ff1b833b51a5a87b0933b2b7c82eb0816e7eba690fee12c7a7cf1",
70+
"existingCohortRetested": 60,
71+
"existingOutputHashesIdentical": true,
72+
"additionalProductionFiles": 16,
73+
"additionalProductionFilesUnchanged": 16,
74+
"additionalProductionOriginalHashesPreserved": true,
75+
"localTests": 46,
76+
"localAssertions": 148,
77+
"alignmentUnitTests": 2,
78+
"alignedIntelligibility": {
79+
"count": 39,
80+
"medianStoiDelta": -0.00025973077349839,
81+
"worstStoiDelta": -0.005509516622390076,
82+
"belowMinusPointZeroOne": 0
83+
}
84+
}
85+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# Instant audio quality experiment
2+
3+
This is an offline, shadow-only experiment. No route, recording finalizer, player,
4+
export, upload, desktop capture path, or production flag imports the worker.
5+
`mode: "off"` returns before filesystem access. There is no publishing mode.
6+
7+
The proposed first rollout is bounded, constant level correction, after production
8+
validation. EQ and denoising remain experimental because consistent perceptual
9+
improvement has not been established. This PR does not enable either profile.
10+
11+
## Evidence and limits
12+
13+
The September 7–8, 2026 study measured 60 additional public, unprotected Instant
14+
recordings with completed transcripts: 20 from each of September 5, 6, and 7,
15+
all from different owners and separate from the initial 12-recording study.
16+
The expanded cohort contains 4.464 hours of audio. Median playback-compensated
17+
loudness is -29.205 LUFS; 40/60 recordings are below -24 LUFS, 9 already exceed
18+
-18 LUFS, and 9 have true peaks above 0 dBTP.
19+
20+
A fixed 39-recording tuning / 21-recording holdout split preceded processing.
21+
The final voice policy passed technical gates on 42/60, left 17 unchanged, and
22+
rejected one short holdout clip for excessive gain. Among the 42 passing clips,
23+
median loudness moved from -31.745 to -16.84 LUFS, median gain was 14.495 dB,
24+
and the highest encoded true peak was -1.34 dBTP. All decoded sample counts were
25+
preserved; container duration changes were at most 21 ms. The reserved holdout
26+
alone had 15 passes, five unchanged, and that one rejected candidate.
27+
Constant-gain processing passed 40/60 and left 20 unchanged, with no rejected
28+
outputs. These counts measure technical eligibility, not listening preference.
29+
30+
Five initial policies produced 195 comparisons. Naive dynamic normalization,
31+
EQ/compression, and denoising each changed container duration by more than 25 ms
32+
on 29/39 recordings. Aggressive processing also damaged synthetic intelligibility
33+
scores. These failures remain in the local evidence; they are not shipping presets.
34+
35+
The adjusted policy uses a 60 Hz high-pass, -0.75 dB at 250 Hz, +0.75 dB at 2.5 kHz,
36+
6 dB adaptive FFT denoising, and loudness normalization. It has no extra compressor.
37+
Input and output gates bound gain and peak level. Separate constant-gain processing
38+
is available for content whose suitability for voice processing is unknown.
39+
40+
The exact final worker was tested on 39 controlled cases at 48 kHz: three reference
41+
voice excerpts, four additive noise types, and three SNRs, plus the unmodified
42+
references. Median STOI change was -0.000260, worst -0.005510; none exceeded the
43+
chosen -0.01 regression tolerance. Six inputs were conservatively left unchanged.
44+
These are relative tests against existing recordings, not clean studio ground
45+
truth, subjective quality ratings, or a matched Loom comparison. The calibration
46+
run with 12 dB denoising exceeded that tolerance in four cases, motivating 6 dB.
47+
48+
Volume-matched RMS in uncaptioned intervals changed by a median +0.096 dB across
49+
41 passing clips, with a maximum increase of 6.841 dB. Twelve voice candidates
50+
changed LRA by more than two LU. Those observations require listening review for
51+
background noise swelling and altered dynamics before enabling voice processing.
52+
53+
The reviewed intelligibility scorer aligns reference, noisy input, and processed
54+
audio to the same overlapping sample interval before computing STOI and SI-SDR.
55+
It retains unaligned scores and the measured lag separately; aligning a score does
56+
not waive timing gates. The 39 exact-worker cases were rescored from their original
57+
artifacts after this correction, with zero cases below the -0.01 tolerance.
58+
59+
Full-recording LUFS and caption-aligned RMS answer different questions. Caption
60+
intervals approximate speech activity; uncaptioned audio is not necessarily noise
61+
or silence. A completed transcript does not establish that a recording contains
62+
only microphone speech. Mono loudness uses FFmpeg's `dual_mono=true` playback
63+
compensation consistently; it must not be mixed with uncompensated mono metrics.
64+
65+
## Source and timing guarantees
66+
67+
The worker only reads an absolute regular local source, hashes it before and after,
68+
and writes into a unique temporary directory. It copies video packets and verifies
69+
them with the existing packet-proof helper. Existing finalization checks are
70+
unchanged. Results carry source/output hashes, metrics, version, and validation
71+
failures; every nonempty validation failure list disqualifies that candidate.
72+
73+
The worker restricts demuxers and protocols to local media files, rejecting playlists
74+
instead of following their references. Only AAC inputs are eligible for processing;
75+
other codecs are left unchanged. A MOV/PCM fixture exposed a video preservation
76+
mismatch, so the first rollout deliberately bypasses that format.
77+
78+
The worker skips silence, extreme levels, existing clipping, unsupported formats,
79+
already loud content, nonzero audio start times, discontinuous source timestamps,
80+
and mismatched source audio/video durations. Voice processing additionally requires
81+
`speechOnlyConfirmed`; the benchmark explicitly overrides this only for local
82+
research. There is no production content classifier in this change.
83+
84+
FFmpeg's denoiser delays content by two sample-advance blocks without adjusting
85+
PTS. Padding the tail and trimming that delay preserves boundary speech. Integer
86+
sample timebases avoid timestamp rounding at 44.1 kHz. The encoded AAC result is
87+
remeasured, with one bounded peak correction rendered from the original if needed.
88+
Failed validation never authorizes publication. Cancellation, timeouts, and exceptions
89+
clean up only the worker's own temporary files.
90+
91+
46 tests cover policy gates, mono/stereo, 44.1/48 kHz, both profiles, speech-like
92+
markers at clip boundaries, exact video packets, source preservation, silence,
93+
nonzero/discontinuous timestamps, cancellation, and timeout. Scoped TypeScript and
94+
Biome checks also pass. Measurements used macOS FFmpeg 8.0.1 and Bun 1.4.0;
95+
the reviewed format suite also runs in both production-image architectures and
96+
in the Railway Docker build. The full production cohort was rerun locally.
97+
98+
## Reproducing
99+
100+
Keep source media, transcripts, per-recording measurements, and customer identifiers
101+
outside the repository. Aggregate results and the frozen worker source hash are in
102+
[audio-quality-benchmark-summary.json](audio-quality-benchmark-summary.json).
103+
The local study retains `final-summary.json`, `final-voice-results.json`,
104+
`final-levels-results.json`, and `report.md`, plus per-recording run receipts.
105+
Interrupted runs and retries are retained separately.
106+
107+
The manifest is a JSON array with `id`, `split` (`tuning` or `holdout`), `stratum`,
108+
`createdAt`, and `duration`. Sources are `sources/<id>.m4a`; transcripts are
109+
`sources/<id>.vtt`. Initial download uses the authenticated Cap CLI for existing
110+
transcripts and the public playlist for audio. No new transcription is requested.
111+
112+
```sh
113+
python3 scripts/benchmark-instant-audio.py /absolute/study --phase baseline
114+
python3 scripts/benchmark-instant-audio.py /absolute/study --phase tuning --policies gain6 gain12 dynamic equalized clean
115+
bun apps/media-server/scripts/benchmark-audio-quality.ts /absolute/study tuning unique-label voice
116+
bun apps/media-server/scripts/benchmark-audio-quality.ts /absolute/study holdout another-label levels
117+
```
118+
119+
Use a new label per run; the worker benchmark will not overwrite existing results.
120+
Rejected candidates are recorded with their validation failures but are not copied
121+
into the output set. Earlier historical runs retained rejected files for diagnosis.
122+
The intelligibility calibration requires NumPy, SciPy, and pystoi. Supply three
123+
reference IDs with `--reference-ids`; their M4A files must be two directories above
124+
the output directory. Output-directory suffix `-v2` selects the corrected mild
125+
policy; a name containing `strength` selects the 6/12 dB comparison. This calibration
126+
script records historical filter alternatives; the TypeScript worker benchmark is
127+
the authoritative final implementation.
128+
129+
## Production-data revalidation
130+
131+
The tighter local-input restrictions were applied to the original 60-recording
132+
cohort again. All 40 accepted outputs had identical hashes to the benchmark outputs;
133+
20 sources were left unchanged. Sixteen additional public production files, including
134+
browser captures and recordings without completed transcripts, were left unchanged
135+
by stream, level, headroom, or timestamp gates. All original hashes were preserved.
136+
These are bounded compatibility checks, not proof of safety for every possible file.
137+
138+
## Before serving any enhanced audio
139+
140+
Human review of the 12 volume-matched A/B excerpts is still required. Speech-only
141+
eligibility, noisy and mixed-system-audio cases, and recordings excluded by the
142+
public/transcribed selection need broader coverage. The short holdout clip rejected
143+
for excessive gain must remain on its original audio; do not relax its gate to make
144+
the benchmark pass.
145+
146+
Run the exact policy in the production Linux image, then verify actual share-page,
147+
embed, seeking, downloads, edits, transcript alignment, and fallback behavior.
148+
Measure worker memory, throughput, storage, and tail latency before rollout.
149+
150+
Future integration should create a separately versioned derivative after the
151+
original is available, using a durable idempotent job bound to the source hash.
152+
Publish atomically only after validation and only if the source still matches.
153+
Keep the original available throughout processing, on failure, and for rollback.
154+
Existing desktop installs could then benefit server-side without a capture update;
155+
this experiment does not yet implement that serving integration.

0 commit comments

Comments
 (0)