Audio-parity TDDs (sound capture / presence + sync + acoustic similarity + loudness diff / cross-impl reference-target)

mandatory

The audio sibling of specs/develop/animation-parity-tdds.kmd (visual motion) and visual-regression-tdds.kmd (static). Every Koder surface that PLAYS sound during an interaction or flow — UI feedback sounds, notification/alert chimes, error beeps, Talk Mode / voice prompts, media playback, device-event sounds (volume change, plug/unplug) — MUST ship audio-parity TDDs. Defines: (1) headless capture of a surface's audio OUTPUT during an interaction (virtual sink + monitor record), (2) the four audio comparison categories (presence/absence, AV sync, acoustic similarity, loudness), (3) a "reference-target" mode comparing against a captured reference impl (e.g. GNOME 48 event sounds) alongside self-golden, and (4) the generation contract + the audio lane of the s.khost1 AV parity engine. Together with animation-parity this makes the parity engine truly **audio-visual**. Capture/compare runs HEADLESS on s.khost1, never on the owner's laptop.

Scope

Every Koder surface that emits audio during an interaction or flow MUST ship audio-parity TDDs under <module>/tests/regression/audio/. Covered sound classes:

  • UI feedback sounds — toggles, presses, drag-drop, error/decline.
  • Event sounds — volume change, device plug/unplug, battery low,

    screenshot shutter, notification/alert chimes.

  • Voice / Talk Mode — wake-word ack, TTS prompts (specs/voice/).
  • Media playback — players, video, ringtones.

Out of scope: surfaces that never emit sound; the content of user-supplied media (we test that the app plays it, not the media itself).

Two comparison modes (mirrors animation-parity § R3). self-golden (vs this surface's own committed audio golden — catches regressions) and, under a parity program, reference-target (vs a captured reference impl, e.g. GNOME 48 — proves "sounds like / behaves like GNOME"). Per interaction, the parity manifest decides whether the same asset, the same acoustic character, or merely the same presence/timing is required (R3).

R1 — Categories (the four audio TDDs)

Category A1 — Presence / absence parity

At each interaction point, assert a sound plays iff it should — same trigger points as the reference, and silence when it must be silent:

∀ interaction i:
  played = (captured RMS over i's window > noise_floor)
  assert played == expected(i)            # self-golden or reference
  # event-sounds disabled → MUST be silent
  with event_sounds = off: assert not played

Catches both regressions (a sound that vanished) and over-eager noise (a sound GNOME does NOT play at that point). Respects the desktop "event sounds" / mute setting — silence under mute is a required assertion, not an exemption.

Category A2 — AV sync parity

The sound must fire at the right moment relative to its visual event (the bridge to animation-parity M-categories):

onset_audio  = first sample where RMS crosses noise_floor
onset_visual = the M-category trigger marker (animation-parity § R2.1 hook)
assert |onset_audio − onset_visual| ≤ 60ms          # perceptual sync floor

60 ms is the perceptual audio-video sync window. The trigger marker is the same debug-hook marker the animation prong uses, so audio and motion share one timeline.

Category A3 — Acoustic similarity

Compare the captured audio to the golden/reference by robust acoustic features, NOT raw waveform (different assets, encoders, sample rates):

feat = log-mel / MFCC / chroma fingerprint of the captured clip
assert feature_distance(feat, golden_feat) ≤ tol(mode, interaction)
  • self-golden — tight tol (the surface's own sound shouldn't drift).
  • reference-target — the manifest (R3) picks the bar per interaction:
    • same-asset (tight — we ship literally GNOME's freedesktop sound),
    • same-character (loose — a distinct Koder sound in the same role:

      shortpercussiverising, etc. — feature-distance within a family band),

    • presence-only (A3 skipped; A1+A2 govern).
  • Self-hosted-first: prefer kodec for decode + feature extraction

    where it has parity; chromaprint/ffmpeg are the documented fallback until kodec flips (policies/self-hosted-first.kmd + registries/self-hosted-pairs.md).

Category A4 — Loudness / level parity

Normalized perceived loudness within range, so no UI sound is jarring:

assert |LUFS(captured) − LUFS(golden_or_target)| ≤ 3 LU
assert true_peak(captured) ≤ −1 dBTP                # no clipping

Default target band from the surface's sound theme; reference-target uses the reference clip's measured LUFS. Prevents the "one sound 12 dB louder than the rest" regression.

R2 — Capture pipeline (headless, on s.khost1)

Runs on s.khost1, never the owner's laptop (heavy-work-isolation.kmd). It is the audio lane of the same AV parity engine as animation-parity § R6 — one run captures video (wf-recorder) + audio together, frame- and sample-aligned by the shared trigger marker.

Step Tool Output
Route surface audio a PipeWire/PulseAudio virtual sink the harness owns isolates the surface's output (no real speakers)
Record the sink's monitor source → wav/flac (or wf-recorder's audio track) the captured clip per interaction
Trigger the animation-parity § R2.1 debug-hook marker (or a synthetic input, synthetic-input-and-virtual-devices.kmd) start/stop + onset timeline
Features kodec (self-hosted-first) / chromaprint fallback A3 fingerprint, A4 LUFS

The surface is launched with PULSE_SINK / a PipeWire target pointing at the harness's virtual sink, so its audio is captured deterministically and never reaches hardware.

R3 — Reference-target mode (vs GNOME etc.)

Mirrors animation-parity § R3. A reference clip per interaction is captured once from the reference impl and committed under <module>/tests/regression/audio/__reference__/<impl>/, with a sidecar <interaction>.meta.json (impl+version, sample rate, measured LUFS, fingerprint). The surface's tests/regression/audio/parity.toml lists, per interaction: reference impl, the bar (same-asset | same-character | presence-only), category tolerances, and the M4-style phase (report-onlyparity-achieved). Re-capture only on a deliberate reference-version bump (recorded in the manifest + commit).

R4 — Generation contract (extends the test-gen)

/k-test MUST emit the audio suite when a module declares it emits sound:

  • koder.toml [ui] with plays_audio = true or a sound_theme = "...".
  • A bundled sound theme / .oga.wav assets under a `sounds` dir.
  • A dependency on the Koder voice SDK (specs/voice/) or a media player

    surface.

Generated tests live under <module>/tests/regression/audio/, named <interaction>-<category>.{spec.ts,dart,kt,sh}. Golden + reference clips ARE committed (golden, not artifact). For surfaces with a deterministic, synthesizable sound (a synth/cue defined in code) the generator also emits a pure test of the cue parameters (the audio analogue of the animation cairo prong) so A1/A4 can run seat-free.

R5 — Run cadence

Trigger Categories Where On failure
Pre-commit (/k-commit) A1 + A4 pure-cue (seat-free, where available) laptop/CI Block commit
Nightly CI A1 + A2 + A3 + A4 (self-golden) s.khost1 Open ticket auto
Pre-release (/k-ship) all + A3/A2 reference-target s.khost1 Block on parity-achieved; report-only otherwise
Parity sweep A1–A4 reference-target, report-only s.khost1 Gap report → tickets

R6 — Reduced/accessibility & determinism

  • Mute / event-sounds-off is a first-class state (A1) — every audio

    TDD includes the silenced case and asserts silence.

  • Determinism — capture is gated on the trigger marker, never a fixed

    sleep; feature distance uses fixed-window analysis, not wall-clock.

  • No PII / no user media in goldens — golden/reference clips are UI

    sounds only; never capture a flow that would record user audio content.

R7 — Forbidden / anti-patterns

  • ❌ Comparing raw waveforms / byte-diffing audio (encoder + asset

    differences) — use acoustic features (A3).

  • ❌ Capturing to real hardware output — virtual sink only (R2).
  • ❌ Asserting a sound "eventually" via sleep — gate on the marker (R6).
  • ❌ Marking parity-achieved without a committed reference clip + passing

    A1–A4 at the manifest's bar.

  • ❌ Shipping a sound fix without an audio TDD that would have caught it

    (policies/regression-tests.kmd).

Tests of the test contract

ID Test
T1 A module with [ui] plays_audio = true gets tests/regression/audio/ populated by /k-test --gen-only.
T2 A1 fails when a UI sound stops playing at an interaction point (regression) AND when a sound plays where the reference is silent (over-eager).
T3 A1 silenced-state: with event-sounds off, the capture is silent (RMS < floor) — fails if a sound leaks.
T4 A2 fails when the chime onset drifts > 60 ms from the visual trigger marker.
T5 A3 same-character: a distinct Koder sound in the same role passes; a wrong-character sound (long pad where a short tick is expected) fails the family band.
T6 A4 fails on a sound mastered 6 dB louder than the theme target / clipping above −1 dBTP.
T7 Reference-target A3 produces a per-interaction gap report vs __reference__/gnome-48/ (feature distance + LUFS Δ + onset Δ).
T8 Self-hosted: A3A4 run via kodec when present; chromaprintffmpeg fallback only when kodec lacks parity.

Relationship to existing specs

  • Audio sibling of animation-parity-tdds.kmd (visual motion) and

    visual-regression-tdds.kmd (static) — A2 binds audio to the visual trigger timeline; together they are the AV parity engine.

  • Consumes synthetic-input-and-virtual-devices.kmd — the virtual

    mic / device-event drivers trigger the flows that produce sound (e.g. plug a virtual BT headset → device-connect sound + audio re-route).

  • Cross-references specs/voice/wake-word.kmd — Talk Mode prompts are

    an audio surface covered here.

  • Bound by policies/self-hosted-first.kmd (kodec for analysis) and

    policies/heavy-work-isolation.kmd (s.khost1).

R8 — Existing live gaps (snapshot at ratification)

At ratification (2026-06-07) no Koder surface ships an audio suite. First consumers: Kolide event sounds (volumeplugnotification — GNOME parity), the voice/Talk Mode surfaces, and any media player. Track closure in meta/docs/stack/registries/audio-parity-coverage.md (new — created with this spec).

Open follow-ups

  • Stand up the audio lane of the s.khost1 AV engine (virtual sink +

    monitor record + kodec features) — KSTACK-179.

  • Capture the first GNOME-48 event-sound reference clips (volume change,

    device plug, notification).

  • Extend /k-test R4 detection + audio template emitters.