Files
Portfolio/docs/superpowers/plans/2026-09-04-portfolio-adaptive-soundtrack.md

3836 lines
165 KiB
Markdown

# Portfolio Adaptive Soundtrack Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Preserve the approved authored Nightdrive score while adding stable four-mood chapter shading, a restrained acceleration response, a synchronized drive-lift layer, and an indefinite seamless afterglow without jarring scroll-triggered transitions.
**Architecture:** Keep one gesture-created `AudioContext`, one uninterrupted musical clock, and the existing intro/main recordings. A pure audio-intent coordinator stabilizes responsive journey geometry and navigation epochs before issuing separate chapter and drive commands; the soundtrack engine routes the authored bed, short synchronized overlays, and a procedural drive texture through a conservative adaptive mixer. New music is produced and listener-approved outside the Portfolio repository before its hash-bound web derivatives enter the existing prerender pipeline.
**Tech Stack:** Vue 3, TypeScript 5.9, Vite 8, Web Audio API, Zod 4, Vitest 4, Playwright 1.62, FFmpeg/FFprobe, Python 3 with NumPy/SoundFile, ACE-Step 1.5 XL-SFT with the 4B planner
**Spec:** `docs/superpowers/specs/2026-09-04-portfolio-adaptive-soundtrack-design.md`
## Global Constraints
- Work only on branch `codex/portfolio-audio-refinement` in `/home/luca/Documents/Portfolio/.worktrees/portfolio-audio-refinement` for Portfolio changes.
- Keep candidates, WAV masters, measurements, prompts, seeds, and audition renders under `/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1` until the user approves both additions.
- Preserve the approved 86 BPM, F minor, 4/4, 64-bar main master unchanged as the dominant source.
- Preserve `intro-in -> intro-idle-first-pass -> intro-idle-loop -> intro-out -> main -> afterglow-in -> afterglow-idle-loop`.
- Keep the main score running through chapter changes and returns to Start; never seek, restart, pitch-shift, time-stretch, or change its playback rate.
- Use exactly four moods: `open`, `shadow`, `lift`, and `resolve`, with a 1.4-second mix ramp.
- Require 6vh boundary hysteresis and 400 ms dwell for passive musical chapter changes.
- Treat explicit navigation as latest-wins, commit only the current settled epoch, and cap its drive energy at `0.35`.
- Apply an audio dead zone of `0.08`, a 220 ms drive-texture attack, and a 900 ms release.
- Arm drive lift only for physical energy above `0.62` for 450 ms; arm its exit below `0.32` for 800 ms; use one-beat fades.
- Emit drive energy at most once per 33 ms and only after a delta of at least `0.02`, except dead-zone, threshold, source, and final-zero crossings.
- Use one four-bar drive-lift loop of exactly 535,814 samples and one afterglow runtime of exactly 1,205,581 samples at 48 kHz.
- Keep the afterglow transition in samples `[0, 133953)` and loop only `[133953, 1205581)`; never repeat the transition bar.
- Keep engine/road texture barely audible at cruise and subordinate to the authored score at maximum acceleration.
- Retain explicit gesture gating, one-context ownership, mute, volume, visibility, interruption, BFCache, authenticated F10-F13 fault hooks, graceful optional-layer failure, and idempotent teardown.
- Keep the exact asset-reference closure and Opus-first/MP3-fallback contract. Do not reduce intro/main quality. Deferred audio must remain at or below 16 MiB.
- Do not restore `music-sequencer.ts`, the old combined procedural synth graph, full-length stems, a dynamics compressor across the master, or a new settings interface.
- Run only the focused checks named in each task. Run the complete compiled-preview/browser gate once at the end rather than repeating it between tasks.
- Treat analyzer and loudness measurements as technical screening only. Musical approval is a human listening decision.
## File Structure
### External production workspace
- `/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/references/`: fixed approved-master excerpts used for conditioning.
- `/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/candidates/`: four fixed-seed raw renders and two exact afterglow runtimes.
- `/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/loops/`: exact eight-bar afterglow-idle and four-bar drive-lift loops.
- `/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/verification/`: decoded derivatives and three-repeat seam renders.
- `/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/auditions/`: in-context A/B listening files.
- `/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/generation/`: ACE-Step task records.
- `/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analysis/`: signal, loudness, silence, seam, checksum, and byte reports.
- `/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/`: listener-approved Opus and MP3 derivatives.
- `/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analyze-one.sh`: persistent analyzer/FFmpeg screening wrapper reused across production steps.
- `/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/build-afterglow-runtime.py`: exact entry-preserving transition/runtime assembler.
- `/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/build-production-manifest.mjs`: one-use deterministic manifest assembler fed by the approved seed pair and saved reports.
- `/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/production-manifest.json`: reproducible source, selection, loop, measurement, and delivery record.
### New Portfolio modules
- `src/audio/audio-intent-coordinator.ts`: pure passive-hysteresis, explicit-settlement, generation, drive classification, and emission-rate state machine.
- `src/audio/drive-texture.ts`: deterministic low engine, stereo road/air texture, ignition envelope, smoothing, and exact-once disposal.
- `src/audio/adaptive-mixer.ts`: fixed-headroom bed routing, four mood profiles, and beat-aligned drive-lift gate.
- `src/composables/useAudioIntent.ts`: shared-frame Vue bridge from journey/navigation snapshots to chapter and drive refs.
- `tests/helpers/fake-audio-context.ts`: call-recording Web Audio fake used only by focused mixer/engine/texture tests.
- `tests/unit/audio-intent-coordinator.test.ts`: stable chapter and drive emission contract.
- `tests/unit/use-audio-intent.test.ts`: first measured deep-link seeding and visibility suppression.
- `tests/unit/drive-texture.test.ts`: node reuse, attack/release automation, deterministic stereo creation, and disposal.
- `tests/unit/adaptive-mixer.test.ts`: mood ramps and sustained physical drive-lift gating.
- `tests/unit/soundtrack-engine.test.ts`: cancellable intro departure, shared clock, optional loading, afterglow, and cleanup.
### Existing Portfolio modules with changed responsibilities
- `src/journey/navigation-controller.ts`: add `idle | travelling | settled` phase to every navigation epoch.
- `src/journey/types.ts`: carry activation-relative geometry and layout revision in `JourneySnapshot`.
- `src/journey/section-controller.ts`: derive normalized active/next-heading boundary offsets without a second DOM read.
- `src/composables/useJourneySections.ts`: expose its measured snapshot, frame request, and post-measurement frame subscription as one binding.
- `src/audio/journey-music-state.ts`: map canonical sections to typed `ChapterIntent` values.
- `src/audio/types.ts`: replace combined journey state with independent chapter and drive commands; add afterglow and drive-lift sources.
- `src/audio/create-audio-controller.ts`: cache, normalize, and forward independent pre-activation command values.
- `src/audio/soundtrack-transport.ts`: own exact beat/bar/sample constants and phase/offset helpers.
- `src/audio/soundtrack-engine.ts`: orchestrate dominant and optional buffer loading, cancellable intro scheduling, one main anchor, afterglow, mixer, texture, and teardown.
- `src/composables/useAudioEngine.ts`: watch chapter and drive refs independently without recreating the controller.
- `src/components/AppShell.vue`: remove eager `pendingAudioDestination`, construct the audio-intent binding, and resolve all four soundtrack regions.
- `src/content/types.ts`, `src/content/schemas.ts`, `content/public/portfolio-content.json`: publish four strict soundtrack regions.
- `content/public/asset-manifest.json`, `inputs/prepared-assets/`: add four hash-bound approved derivatives.
- `scripts/materialize-empty-asset-set.mjs`: include all four soundtrack regions in exact reference closure.
- `scripts/lib/bundle-budgets.mjs`, `scripts/lib/bundle-budgets.d.mts`, `scripts/check-bundle-budgets.mjs`: classify the two new filenames and enforce the 16 MiB aggregate cap.
- Existing unit/integration/browser tests named below: update only contracts affected by these changes.
---
### Task 1: Checkpoint the existing authored-soundtrack baseline
**Files:**
- Commit first: the current authored intro/main integration already present in the worktree
- Commit separately: `docs/superpowers/plans/2026-09-04-portfolio-adaptive-soundtrack.md`
**Interfaces:**
- Consumes: the current dirty worktree containing the approved intro, main master, exact manifest, lifecycle controller, and compiled-preview fixes.
- Produces: one named baseline commit so later adaptive commits remain reviewable without rewriting or losing the user's existing work.
- [ ] **Step 1: Confirm the worktree and preserve every existing change**
Run:
```bash
git branch --show-current
git status --short
git diff --check
```
Expected: branch is `codex/portfolio-audio-refinement`; the status contains the authored-soundtrack paths recorded below; `git diff --check` prints nothing. Do not reset, restore, or clean this worktree.
- [ ] **Step 2: Run only the baseline-focused checks**
Run:
```bash
npm run typecheck
npm run test:unit -- tests/unit/audio-lifecycle.test.ts tests/unit/soundtrack-transport.test.ts tests/unit/content-schemas.test.ts tests/integration/public-content.test.ts tests/integration/materialize-empty-asset-set.test.mjs tests/integration/verify-artifacts.test.ts tests/performance/budgets.test.ts
npm run verify:approved-assets
```
Expected: all three commands pass. Stop and fix only a baseline regression if one fails; do not begin adaptive work on a failing baseline.
- [ ] **Step 3: Stage the exact authored baseline**
Run:
```bash
git add content/public/asset-manifest.json content/public/portfolio-content.json nginx.portfolio.conf.example
git add scripts/check-bundle-budgets.mjs scripts/lib/bundle-budgets.d.mts scripts/lib/bundle-budgets.mjs scripts/materialize-empty-asset-set.mjs scripts/smoke-production.mjs scripts/verify-artifacts.mjs
git add src/App.vue src/audio/create-audio-controller.ts src/audio/music-sequencer.ts src/audio/synth-engine.ts src/audio/types.ts src/audio/journey-music-state.ts src/audio/soundtrack-engine.ts src/audio/soundtrack-transport.ts src/components/AppShell.vue src/composables/useAudioEngine.ts src/content/schemas.ts src/content/types.ts src/security/security-policy.ts
git add tests/browser/audio-controls.spec.ts tests/deployment/seo.spec.ts tests/helpers/serve-dist.ts tests/integration/materialize-empty-asset-set.test.mjs tests/integration/nginx-config.test.ts tests/integration/public-content.test.ts tests/integration/verify-artifacts.test.ts tests/performance/budgets.test.ts tests/unit/audio-lifecycle.test.ts tests/unit/content-schemas.test.ts tests/unit/metadata.test.ts tests/unit/security-policy.test.ts tests/unit/soundtrack-transport.test.ts
git add inputs/prepared-assets/nightdrive-intro.opus inputs/prepared-assets/nightdrive-intro.mp3 inputs/prepared-assets/nightdrive-main.opus inputs/prepared-assets/nightdrive-main.mp3
git diff --cached --stat
```
Expected: the staged diff contains the current authored soundtrack and supporting delivery changes, including deletion of the rejected procedural sequencer/synth files. It does not contain the adaptive plan document or unrelated files.
- [ ] **Step 4: Commit the baseline**
Run:
```bash
git commit -m "feat: integrate authored nightdrive soundtrack"
```
Expected: one commit is created. `git status --short` contains only the plan document.
- [ ] **Step 5: Preserve the approved execution plan as its own documentation commit**
Run:
```bash
git add docs/superpowers/plans/2026-09-04-portfolio-adaptive-soundtrack.md
git commit -m "docs: plan adaptive nightdrive soundtrack"
```
Expected: the plan has its own commit and `git status --short` is empty before external music production begins.
### Task 2: Produce and approve the short adaptive music additions
**Files:**
- Create: `/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/**`
- Read: `/home/luca/Projects/nightdrive-music/generated/production/hybrid-v1/main-score-64bars-master-candidate-v4.wav`
- Read: `/home/luca/Projects/nightdrive-music/skills/nightdrive-music/scripts/ace_client.py`
- Read: `/home/luca/Projects/nightdrive-music/skills/nightdrive-music/scripts/build_loop.py`
- Read: `/home/luca/Projects/nightdrive-music/skills/nightdrive-music/scripts/analyze_audio.py`
- Portfolio repository: no changes
**Interfaces:**
- Consumes: approved 48 kHz main master, ACE-Step XL-SFT, 4B planner, 86 BPM/F minor/4/4, fixed seeds `44201`, `44202`, `44301`, and `44302`.
- Produces: listener-approved `nightdrive-afterglow-runtime.wav` and `nightdrive-drive-lift.wav`, four web derivatives, exact sample/loop metadata, and `production-manifest.json`.
- [ ] **Step 1: Verify the local model contract and start the API only if needed**
Run in the production shell:
```bash
test -L /home/luca/Projects/nightdrive-music/ACE-Step-1.5/checkpoints
test "$(readlink /home/luca/Projects/nightdrive-music/ACE-Step-1.5/checkpoints)" = "../checkpoints"
mkdir -p /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/{references,candidates,loops,verification,auditions,generation,analysis,web}
curl --silent --show-error http://127.0.0.1:8001/health
```
Expected: both symlink checks succeed. If health is unavailable, start the service in a separate terminal with the approved CPU offload enabled:
```bash
cd /home/luca/Projects/nightdrive-music/ACE-Step-1.5
ACESTEP_OFFLOAD_TO_CPU=true \
ACESTEP_CONFIG_PATH=acestep-v15-xl-sft \
ACESTEP_INIT_LLM=true \
ACESTEP_LM_MODEL_PATH=acestep-5Hz-lm-4B \
uv run acestep-api --host 127.0.0.1 --port 8001 \
--init-llm --lm-model-path acestep-5Hz-lm-4B
```
Then run:
```bash
curl --silent --show-error http://127.0.0.1:8001/health | jq -e '.code == 200 and .data.status == "ok" and .data.models_initialized == true and .data.llm_initialized == true and .data.loaded_model == "acestep-v15-xl-sft" and .data.loaded_lm_model == "acestep-5Hz-lm-4B"'
```
Expected: `jq` exits zero and prints `true`. Do not replace the checkpoint symlink or download another model.
- [ ] **Step 2: Verify the approved master and cut deterministic conditioning excerpts**
Run:
```bash
printf '%s %s\n' 'f232dfd0f012c3c7aeef6013a36a3695e82cdf0de2e01aa2e8a95b2c2dd8cc3f' '/home/luca/Projects/nightdrive-music/generated/production/hybrid-v1/main-score-64bars-master-candidate-v4.wav' | sha256sum --check
ffmpeg -v error -y -i /home/luca/Projects/nightdrive-music/generated/production/hybrid-v1/main-score-64bars-master-candidate-v4.wav -af 'atrim=start_sample=7133023:end_sample=8477023,asetpts=PTS-STARTPTS' -ar 48000 -ac 2 -c:a pcm_f32le /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/references/approved-main-afterglow-reference.wav
ffmpeg -v error -y -i /home/luca/Projects/nightdrive-music/generated/production/hybrid-v1/main-score-64bars-master-candidate-v4.wav -af 'atrim=start_sample=6429767:end_sample=7501395,asetpts=PTS-STARTPTS' -ar 48000 -ac 2 -c:a pcm_f32le /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/references/approved-main-drive-lift-reference.wav
```
Expected: the checksum reports `OK`; the afterglow reference is 1,344,000 samples and ends before the approved final fade; the drive reference is 1,071,628 samples and covers the final-lift phrase.
- [ ] **Step 3: Generate two fixed-seed afterglows and two fixed-seed drive lifts**
Run in a shell with pipeline failure propagation:
```bash
set -o pipefail
AFTERGLOW_PROMPT='Original instrumental adaptive afterglow companion for the supplied Nightdrive master at 86 BPM, F minor, 4/4. Write one non-repeating transition bar followed by an eight-bar seamless low-energy idle phrase and at least 250 milliseconds of musically continuous handle audio, all within a 30-second render. The transition must begin understated and naturally emerge beneath a fading master. The eight-bar region must be fatigue-resistant and circular, with warm glassy atmospheric pads, a faint pulse, restrained texture, gentle inner motion, no exposed lead, no obvious restart, and no fade to silence. Its ending and continuation must return naturally to its opening. Match the reference only for production identity, timbre, density, stereo character, and groove; compose entirely original notes and arrangement. No vocals, speech, bass lead, dramatic drums, EDM drop, riser, impact, trailer effect, harsh glitch, isolated bleeps, piercing pulse, or huge reverb.'
DRIVE_PROMPT='Original instrumental adaptive acceleration layer for the supplied Nightdrive master at 86 BPM, F minor, 4/4. Write four exact-feeling bars followed by at least 250 milliseconds of musically continuous handle audio within a 16-second render. Use restrained electronic percussion and a glassy non-dominant shimmer only: clean pulse, subtle syncopation, soft transient detail, wide but mono-safe air, and enough rhythmic identity to reward sustained acceleration while remaining clearly subordinate to background music. The ending and continuation must return naturally to the opening. Match the reference only for production identity, timbre, stereo character, and groove; compose entirely original material. No vocals, speech, bass line, chord bed, pad bed, melody, solo, fill, riser, impact, drop, trailer effect, harsh glitch, isolated bleeps, or fade to silence.'
printf '%s\n' "$AFTERGLOW_PROMPT" > /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/generation/afterglow-prompt.txt
printf '%s\n' "$DRIVE_PROMPT" > /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/generation/drive-lift-prompt.txt
for ADAPTIVE_SEED in 44201 44202; do
/home/luca/Projects/nightdrive-music/ACE-Step-1.5/.venv/bin/python /home/luca/Projects/nightdrive-music/skills/nightdrive-music/scripts/ace_client.py --prompt "$AFTERGLOW_PROMPT" --output "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/candidates/afterglow-seed-${ADAPTIVE_SEED}-source.wav" --duration 30 --bpm 86 --key 'F minor' --seed "$ADAPTIVE_SEED" --reference-audio /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/references/approved-main-afterglow-reference.wav --timeout 900 | tee "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/generation/afterglow-seed-${ADAPTIVE_SEED}.json"
done
for ADAPTIVE_SEED in 44301 44302; do
/home/luca/Projects/nightdrive-music/ACE-Step-1.5/.venv/bin/python /home/luca/Projects/nightdrive-music/skills/nightdrive-music/scripts/ace_client.py --prompt "$DRIVE_PROMPT" --output "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/candidates/drive-lift-seed-${ADAPTIVE_SEED}-source.wav" --duration 16 --bpm 86 --key 'F minor' --seed "$ADAPTIVE_SEED" --reference-audio /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/references/approved-main-drive-lift-reference.wav --timeout 900 | tee "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/generation/drive-lift-seed-${ADAPTIVE_SEED}.json"
done
```
Expected: four successful JSON task records and four WAVs. The only within-asset generation variable is the fixed seed.
- [ ] **Step 4: Screen every raw render technically before listening**
Create `/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analyze-one.sh` with `apply_patch`:
```bash
#!/usr/bin/env bash
set -euo pipefail
AUDIO_PATH="$1"
REPORT_STEM="$2"
ANALYSIS_ROOT='/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analysis'
/home/luca/Projects/nightdrive-music/ACE-Step-1.5/.venv/bin/python \
/home/luca/Projects/nightdrive-music/skills/nightdrive-music/scripts/analyze_audio.py \
"$AUDIO_PATH" | tee "${ANALYSIS_ROOT}/${REPORT_STEM}-signal.json"
ffmpeg -hide_banner -nostats -i "$AUDIO_PATH" \
-filter_complex 'ebur128=peak=true' -f null - 2>&1 | \
tee "${ANALYSIS_ROOT}/${REPORT_STEM}-ebur128.txt"
ffmpeg -hide_banner -nostats -i "$AUDIO_PATH" \
-af 'silencedetect=noise=-60dB:d=0.5' -f null - 2>&1 | \
tee "${ANALYSIS_ROOT}/${REPORT_STEM}-silence.txt"
```
Run it on every raw render and both references:
```bash
bash /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analyze-one.sh /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/references/approved-main-afterglow-reference.wav approved-main-afterglow-reference
bash /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analyze-one.sh /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/references/approved-main-drive-lift-reference.wav approved-main-drive-lift-reference
for ADAPTIVE_SEED in 44201 44202; do
bash /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analyze-one.sh "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/candidates/afterglow-seed-${ADAPTIVE_SEED}-source.wav" "afterglow-seed-${ADAPTIVE_SEED}-source"
done
for ADAPTIVE_SEED in 44301 44302; do
bash /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analyze-one.sh "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/candidates/drive-lift-seed-${ADAPTIVE_SEED}-source.wav" "drive-lift-seed-${ADAPTIVE_SEED}-source"
done
```
Expected: every survivor is finite 48 kHz stereo, remains below full scale and true-peak clipping, has no sustained unintended silence, is long enough for its exact loop plus handle, and is not collapsed in width or spectrum relative to its conditioning excerpt. Reject unintended vocals by listening. Do not rank composition quality from these reports.
- [ ] **Step 5: Build exact loops and exact afterglow runtimes**
Create `/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/build-afterglow-runtime.py` with `apply_patch`. This keeps the loop builder's optimized recurring seam, then makes the last 250 ms of the one-shot transition approach the raw idle tail that naturally precedes that optimized loop opening. It therefore preserves both `transition -> first idle` and `idle -> repeated idle` instead of fixing one seam at the expense of the other:
```py
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
import numpy as np
import soundfile as sf
SAMPLE_RATE = 48_000
TRANSITION_SAMPLES = 133_953
LOOP_SAMPLES = 1_071_628
CROSSFADE_SAMPLES = 12_000
def seam_delta(left: np.ndarray, right: np.ndarray) -> float:
return float(np.linalg.norm(right - left))
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument('source', type=Path)
parser.add_argument('loop', type=Path)
parser.add_argument('output', type=Path)
args = parser.parse_args()
source, source_rate = sf.read(args.source, always_2d=True, dtype='float64')
loop, loop_rate = sf.read(args.loop, always_2d=True, dtype='float64')
required_source_samples = TRANSITION_SAMPLES + LOOP_SAMPLES + CROSSFADE_SAMPLES
if source_rate != SAMPLE_RATE or loop_rate != SAMPLE_RATE:
raise ValueError('Afterglow source and loop must both be 48 kHz.')
if source.shape[1] != 2 or loop.shape[1] != 2:
raise ValueError('Afterglow source and loop must both be stereo.')
if len(source) < required_source_samples or len(loop) != LOOP_SAMPLES:
raise ValueError('Afterglow source handle or exact loop length is invalid.')
transition = source[:TRANSITION_SAMPLES].copy()
raw_idle_tail_start = TRANSITION_SAMPLES + LOOP_SAMPLES - CROSSFADE_SAMPLES
raw_idle_tail = source[raw_idle_tail_start:raw_idle_tail_start + CROSSFADE_SAMPLES]
phase = np.linspace(0.0, np.pi / 2.0, CROSSFADE_SAMPLES, endpoint=True)
fade_out = np.square(np.cos(phase))[:, None]
fade_in = np.square(np.sin(phase))[:, None]
transition[-CROSSFADE_SAMPLES:] = (
transition[-CROSSFADE_SAMPLES:] * fade_out + raw_idle_tail * fade_in
)
runtime = np.concatenate([transition, loop], axis=0)
if len(runtime) != TRANSITION_SAMPLES + LOOP_SAMPLES:
raise AssertionError('Afterglow runtime length changed during assembly.')
args.output.parent.mkdir(parents=True, exist_ok=True)
sf.write(args.output, runtime, SAMPLE_RATE, subtype='FLOAT')
ordinary_deltas = np.linalg.norm(np.diff(runtime, axis=0), axis=1)
typical = float(np.median(ordinary_deltas))
entry = seam_delta(runtime[TRANSITION_SAMPLES - 1], runtime[TRANSITION_SAMPLES])
recurring = seam_delta(runtime[-1], runtime[TRANSITION_SAMPLES])
print(json.dumps({
'source': str(args.source.resolve()),
'loop': str(args.loop.resolve()),
'output': str(args.output.resolve()),
'sample_rate': SAMPLE_RATE,
'transition_samples': TRANSITION_SAMPLES,
'loop_samples': LOOP_SAMPLES,
'runtime_samples': len(runtime),
'crossfade_samples': CROSSFADE_SAMPLES,
'transition_to_idle_seam_delta': entry,
'transition_to_idle_vs_typical_ratio': entry / typical if typical > 0 else None,
'idle_to_idle_seam_delta': recurring,
'idle_to_idle_vs_typical_ratio': recurring / typical if typical > 0 else None,
}, indent=2, sort_keys=True))
if __name__ == '__main__':
main()
```
Then run:
```bash
for ADAPTIVE_SEED in 44201 44202; do
/home/luca/Projects/nightdrive-music/ACE-Step-1.5/.venv/bin/python /home/luca/Projects/nightdrive-music/skills/nightdrive-music/scripts/build_loop.py "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/candidates/afterglow-seed-${ADAPTIVE_SEED}-source.wav" "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/loops/afterglow-seed-${ADAPTIVE_SEED}-idle-loop.wav" --bpm 86 --bars 8 --beats-per-bar 4 --start-seconds 2.7906875 --crossfade-ms 250 | tee "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analysis/afterglow-seed-${ADAPTIVE_SEED}-loop-build.json"
/home/luca/Projects/nightdrive-music/ACE-Step-1.5/.venv/bin/python /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/build-afterglow-runtime.py "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/candidates/afterglow-seed-${ADAPTIVE_SEED}-source.wav" "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/loops/afterglow-seed-${ADAPTIVE_SEED}-idle-loop.wav" "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/candidates/afterglow-seed-${ADAPTIVE_SEED}-runtime.wav" | tee "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analysis/afterglow-seed-${ADAPTIVE_SEED}-runtime-build.json"
done
for ADAPTIVE_SEED in 44301 44302; do
/home/luca/Projects/nightdrive-music/ACE-Step-1.5/.venv/bin/python /home/luca/Projects/nightdrive-music/skills/nightdrive-music/scripts/build_loop.py "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/candidates/drive-lift-seed-${ADAPTIVE_SEED}-source.wav" "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/loops/drive-lift-seed-${ADAPTIVE_SEED}-loop.wav" --bpm 86 --bars 4 --beats-per-bar 4 --start-seconds 0 --crossfade-ms 250 | tee "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analysis/drive-lift-seed-${ADAPTIVE_SEED}-loop-build.json"
done
```
Expected: each afterglow idle is 1,071,628 samples; each runtime is 1,205,581 samples; each drive lift is 535,814 samples. Both afterglow seam ratios are finite in every runtime-build record. These ratios screen discontinuities only; both seams still require listening in Steps 7 and 8.
- [ ] **Step 6: Render three repetitions and build the two listening comparisons**
Run:
```bash
for ADAPTIVE_SEED in 44201 44202; do
LOOP_PATH="/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/loops/afterglow-seed-${ADAPTIVE_SEED}-idle-loop.wav"
ffmpeg -v error -y -i "$LOOP_PATH" -i "$LOOP_PATH" -i "$LOOP_PATH" -filter_complex '[0:a][1:a][2:a]concat=n=3:v=0:a=1[out]' -map '[out]' -ar 48000 -ac 2 -c:a pcm_f32le "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/verification/afterglow-seed-${ADAPTIVE_SEED}-idle-loop-3x.wav"
ffmpeg -v error -y -i /home/luca/Projects/nightdrive-music/generated/production/hybrid-v1/main-score-64bars-master-candidate-v4.wav -i "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/candidates/afterglow-seed-${ADAPTIVE_SEED}-runtime.wav" -i "$LOOP_PATH" -filter_complex '[0:a]atrim=start_sample=8305116:end_sample=8573023,asetpts=PTS-STARTPTS,volume=0.489778819[main];[1:a][2:a]concat=n=2:v=0:a=1,afade=t=in:ss=0:ns=133953:curve=tri,volume=0.489778819[after];[after]adelay=delays=133954S:all=1[after-delayed];[main][after-delayed]amix=inputs=2:duration=longest:normalize=0[out]' -map '[out]' -ar 48000 -ac 2 -c:a pcm_f32le "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/auditions/afterglow-seed-${ADAPTIVE_SEED}-handoff-and-seam.wav"
done
for ADAPTIVE_SEED in 44301 44302; do
LOOP_PATH="/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/loops/drive-lift-seed-${ADAPTIVE_SEED}-loop.wav"
ffmpeg -v error -y -i "$LOOP_PATH" -i "$LOOP_PATH" -i "$LOOP_PATH" -filter_complex '[0:a][1:a][2:a]concat=n=3:v=0:a=1[out]' -map '[out]' -ar 48000 -ac 2 -c:a pcm_f32le "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/verification/drive-lift-seed-${ADAPTIVE_SEED}-loop-3x.wav"
ffmpeg -v error -y -i /home/luca/Projects/nightdrive-music/generated/production/hybrid-v1/main-score-64bars-master-candidate-v4.wav -i "$LOOP_PATH" -filter_complex '[0:a]asplit=4[m0][m1][m2][m3];[m0]atrim=start_sample=1071628:end_sample=1607442,asetpts=PTS-STARTPTS[o];[m1]atrim=start_sample=2143256:end_sample=2679070,asetpts=PTS-STARTPTS[s];[m2]atrim=start_sample=6429767:end_sample=6965581,asetpts=PTS-STARTPTS[l];[m3]atrim=start_sample=7501395:end_sample=8037209,asetpts=PTS-STARTPTS[r];[o][s][l][r]concat=n=4:v=0:a=1,volume=0.841395142[bed];[1:a]asplit=4[d0s][d1s][d2s][d3s];[d0s]afade=t=in:ss=33488:ns=33488:curve=tri,afade=t=out:ss=502326:ns=33488:curve=tri[d0];[d1s]afade=t=in:ss=33488:ns=33488:curve=tri,afade=t=out:ss=502326:ns=33488:curve=tri[d1];[d2s]afade=t=in:ss=33488:ns=33488:curve=tri,afade=t=out:ss=502326:ns=33488:curve=tri[d2];[d3s]afade=t=in:ss=33488:ns=33488:curve=tri,afade=t=out:ss=502326:ns=33488:curve=tri[d3];[d0][d1][d2][d3]concat=n=4:v=0:a=1,volume=0.125892541[lift];[bed][lift]amix=inputs=2:duration=shortest:normalize=0[out]' -map '[out]' -ar 48000 -ac 2 -c:a pcm_f32le "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/auditions/drive-lift-seed-${ADAPTIVE_SEED}-four-moods.wav"
done
```
Expected: afterglow three-repeat files are 3,214,884 samples; drive three-repeat files are 1,607,442 samples; afterglow auditions are 2,411,163 samples; drive auditions are 2,143,256 samples.
- [ ] **Step 7: Verify samples, analyze the built files, and listen across every seam**
Run the persisted helper from Step 4 on every exact build with deterministic report names:
```bash
for ADAPTIVE_SEED in 44201 44202; do
bash /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analyze-one.sh "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/loops/afterglow-seed-${ADAPTIVE_SEED}-idle-loop.wav" "afterglow-seed-${ADAPTIVE_SEED}-idle-loop"
bash /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analyze-one.sh "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/candidates/afterglow-seed-${ADAPTIVE_SEED}-runtime.wav" "afterglow-seed-${ADAPTIVE_SEED}-runtime"
bash /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analyze-one.sh "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/verification/afterglow-seed-${ADAPTIVE_SEED}-idle-loop-3x.wav" "afterglow-seed-${ADAPTIVE_SEED}-idle-loop-3x"
bash /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analyze-one.sh "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/auditions/afterglow-seed-${ADAPTIVE_SEED}-handoff-and-seam.wav" "afterglow-seed-${ADAPTIVE_SEED}-audition"
done
for ADAPTIVE_SEED in 44301 44302; do
bash /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analyze-one.sh "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/loops/drive-lift-seed-${ADAPTIVE_SEED}-loop.wav" "drive-lift-seed-${ADAPTIVE_SEED}-loop"
bash /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analyze-one.sh "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/verification/drive-lift-seed-${ADAPTIVE_SEED}-loop-3x.wav" "drive-lift-seed-${ADAPTIVE_SEED}-loop-3x"
bash /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analyze-one.sh "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/auditions/drive-lift-seed-${ADAPTIVE_SEED}-four-moods.wav" "drive-lift-seed-${ADAPTIVE_SEED}-audition"
done
```
For each built file, run:
```bash
ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate,channels,duration_ts -of json /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/candidates/afterglow-seed-44201-runtime.wav
ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate,channels,duration_ts -of json /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/loops/drive-lift-seed-44301-loop.wav
```
Repeat the two probes for seeds `44202` and `44302`. Expected: exact counts from Step 6, finite stereo output, no new clipping, and no unintended silence. Listen to all three-repeat files at both seams and to all four-mood drive excerpts.
- [ ] **Step 8: Stop for the musical listening checkpoint**
Present only the strongest afterglow audition and strongest drive audition, with one runner-up only if the choice is genuinely ambiguous. Use playable local audio, for example:
```markdown
![Afterglow — seed 44201](/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/auditions/afterglow-seed-44201-handoff-and-seam.wav)
![Drive lift — seed 44301](/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/auditions/drive-lift-seed-44301-four-moods.wav)
```
State the exact seed and reference path for each file, and state that measurements establish technical fitness rather than musical quality. Ask for explicit approval of both additions. Do not edit or copy anything into the Portfolio repository before approval. If neither drive candidate remains unobtrusive across all four excerpts, stop and ask whether to omit drive lift or continue production.
- [ ] **Step 9: Canonicalize only the approved pair and encode the web derivatives**
After explicit approval, run exactly one command from each pair:
```bash
cp /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/candidates/afterglow-seed-44201-runtime.wav /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/nightdrive-afterglow-runtime.wav
cp /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/candidates/afterglow-seed-44202-runtime.wav /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/nightdrive-afterglow-runtime.wav
```
```bash
cp /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/loops/drive-lift-seed-44301-loop.wav /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/nightdrive-drive-lift.wav
cp /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/loops/drive-lift-seed-44302-loop.wav /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/nightdrive-drive-lift.wav
```
Then run:
```bash
ffmpeg -v error -y -i /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/nightdrive-afterglow-runtime.wav -map 0:a:0 -ar 48000 -ac 2 -c:a libopus -b:a 160k -vbr on -compression_level 10 /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-afterglow.opus
ffmpeg -v error -y -i /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/nightdrive-afterglow-runtime.wav -map 0:a:0 -ar 48000 -ac 2 -c:a libmp3lame -b:a 192k -write_xing 1 /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-afterglow.mp3
ffmpeg -v error -y -i /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/nightdrive-drive-lift.wav -map 0:a:0 -ar 48000 -ac 2 -c:a libopus -b:a 160k -vbr on -compression_level 10 /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-drive-lift.opus
ffmpeg -v error -y -i /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/nightdrive-drive-lift.wav -map 0:a:0 -ar 48000 -ac 2 -c:a libmp3lame -b:a 192k -write_xing 1 /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-drive-lift.mp3
```
Expected: four short derivatives are created without modifying the existing intro/main encodes.
- [ ] **Step 10: Decode, verify, checksum, and record the approved delivery**
Run:
```bash
for CODEC in opus mp3; do
ffmpeg -v error -y -i "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-afterglow.${CODEC}" -ar 48000 -ac 2 -c:a pcm_f32le "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/verification/nightdrive-afterglow-${CODEC}-decoded.wav"
ffmpeg -v error -y -i "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-drive-lift.${CODEC}" -ar 48000 -ac 2 -c:a pcm_f32le "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/verification/nightdrive-drive-lift-${CODEC}-decoded.wav"
done
for DECODED_PATH in /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/verification/nightdrive-*-decoded.wav; do
ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate,channels,duration_ts -of json "$DECODED_PATH"
done
sha256sum /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/nightdrive-afterglow-runtime.wav /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/nightdrive-drive-lift.wav /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-afterglow.opus /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-afterglow.mp3 /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-drive-lift.opus /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-drive-lift.mp3 | tee /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analysis/delivery-sha256.txt
stat -c '%n %s' /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-afterglow.opus /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-afterglow.mp3 /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-drive-lift.opus /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-drive-lift.mp3 | tee /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analysis/web-bytes.txt
stat -c '%s' /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-afterglow.opus /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-afterglow.mp3 /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-drive-lift.opus /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-drive-lift.mp3 | awk '{new_bytes += $1} END {total_bytes = 13603356 + new_bytes; printf "deferred audio: %d / 16777216 bytes\n", total_bytes; exit(total_bytes > 16777216)}'
```
Expected: decoded afterglow files report 1,205,581 samples; decoded drive files report 535,814 samples; the byte precheck exits zero below 16,777,216 bytes. Analyze all four decoded WAVs with the persisted Step 4 helper and compare them with their masters; do not normalize or remaster them.
Run the analyzer on every decoded derivative with fixed report names:
```bash
for CODEC in opus mp3; do
bash /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analyze-one.sh "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/verification/nightdrive-afterglow-${CODEC}-decoded.wav" "nightdrive-afterglow-${CODEC}-decoded"
bash /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/analyze-one.sh "/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/verification/nightdrive-drive-lift-${CODEC}-decoded.wav" "nightdrive-drive-lift-${CODEC}-decoded"
done
```
Expected: all four analyzer records are finite stereo, the lossy encodes introduce no sustained silence or clipping, and their decoded durations remain exact.
- [ ] **Step 11: Assemble the production manifest deterministically**
Create `/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/build-production-manifest.mjs` with `apply_patch`:
```js
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { readFile, stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
const ROOT = '/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1';
const APPROVED_MAIN = '/home/luca/Projects/nightdrive-music/generated/production/hybrid-v1/main-score-64bars-master-candidate-v4.wav';
const EXISTING_AUDIO_BYTES = 13_603_356;
const DEFERRED_AUDIO_CAP_BYTES = 16_777_216;
const AFTERGLOW_SEEDS = new Set([44201, 44202]);
const DRIVE_SEEDS = new Set([44301, 44302]);
const approvedAfterglowSeed = Number(process.env.ADAPTIVE_AFTERGLOW_SEED);
const approvedDriveLiftSeed = Number(process.env.ADAPTIVE_DRIVE_SEED);
if (!AFTERGLOW_SEEDS.has(approvedAfterglowSeed)) {
throw new Error('ADAPTIVE_AFTERGLOW_SEED must be 44201 or 44202');
}
if (!DRIVE_SEEDS.has(approvedDriveLiftSeed)) {
throw new Error('ADAPTIVE_DRIVE_SEED must be 44301 or 44302');
}
const pathAt = (...parts) => join(ROOT, ...parts);
const readText = (path) => readFile(path, 'utf8');
const readJson = async (path) => JSON.parse(await readText(path));
async function sha256(path) {
const hash = createHash('sha256');
hash.update(await readFile(path));
return hash.digest('hex');
}
async function fileRecord(path) {
const metadata = await stat(path);
return { path, bytes: metadata.size, sha256: await sha256(path) };
}
function probe(path) {
const output = execFileSync('ffprobe', [
'-v', 'error',
'-select_streams', 'a:0',
'-show_entries', 'stream=sample_rate,channels,duration_ts,codec_name',
'-of', 'json',
path,
], { encoding: 'utf8' });
const stream = JSON.parse(output).streams?.[0];
if (!stream) throw new Error(`No audio stream in ${path}`);
return {
codec: stream.codec_name,
sampleRate: Number(stream.sample_rate),
channels: Number(stream.channels),
durationSamples: Number(stream.duration_ts),
};
}
function expectAudio(path, expectedSamples) {
const result = probe(path);
if (result.sampleRate !== 48_000 || result.channels !== 2 || result.durationSamples !== expectedSamples) {
throw new Error(`Unexpected audio contract for ${path}: ${JSON.stringify(result)}`);
}
return result;
}
function parseEbur128(text, reportPath) {
const summary = text.slice(text.lastIndexOf('Summary:'));
const number = (pattern, label) => {
const match = summary.match(pattern);
if (!match) throw new Error(`Missing ${label} in ${reportPath}`);
return Number(match[1]);
};
return {
integratedLufs: number(/I:\s+(-?\d+(?:\.\d+)?)\s+LUFS/, 'integrated loudness'),
loudnessRangeLu: number(/LRA:\s+(\d+(?:\.\d+)?)\s+LU/, 'loudness range'),
truePeakDbfs: number(/Peak:\s+(-?\d+(?:\.\d+)?)\s+dBFS/, 'true peak'),
};
}
async function technicalScreen(stem) {
const signalPath = pathAt('analysis', `${stem}-signal.json`);
const eburPath = pathAt('analysis', `${stem}-ebur128.txt`);
const silencePath = pathAt('analysis', `${stem}-silence.txt`);
const silenceText = await readText(silencePath);
return {
analyzer: await readJson(signalPath),
ffmpegEbur128: parseEbur128(await readText(eburPath), eburPath),
ffmpegSilenceEvents: silenceText.split('\n').filter((line) => line.includes('silence_')),
reportFiles: await Promise.all([signalPath, eburPath, silencePath].map(fileRecord)),
};
}
async function candidate(kind, seed, approved) {
const prefix = kind === 'afterglow' ? 'afterglow' : 'drive-lift';
const sourceStem = `${prefix}-seed-${seed}-source`;
const sourcePath = pathAt('candidates', `${sourceStem}.wav`);
const generationPath = pathAt('generation', `${prefix}-seed-${seed}.json`);
const promptPath = pathAt('generation', `${kind === 'afterglow' ? 'afterglow' : 'drive-lift'}-prompt.txt`);
return {
kind,
seed,
referencePath: pathAt('references', kind === 'afterglow'
? 'approved-main-afterglow-reference.wav'
: 'approved-main-drive-lift-reference.wav'),
prompt: (await readText(promptPath)).trim(),
generation: await readJson(generationPath),
source: await fileRecord(sourcePath),
technicalScreen: await technicalScreen(sourceStem),
disposition: approved ? 'listener-approved' : 'technically-valid-not-selected-by-listener',
};
}
async function derivative(kind, codec, expectedSamples) {
const base = kind === 'afterglow' ? 'nightdrive-afterglow' : 'nightdrive-drive-lift';
const encodedPath = pathAt('web', `${base}.${codec}`);
const decodedStem = `${base}-${codec}-decoded`;
const decodedPath = pathAt('verification', `${decodedStem}.wav`);
return {
kind,
codec,
encoded: await fileRecord(encodedPath),
decoded: await fileRecord(decodedPath),
decodedProbe: expectAudio(decodedPath, expectedSamples),
decodedTechnicalScreen: await technicalScreen(decodedStem),
};
}
const afterglowReference = pathAt('references', 'approved-main-afterglow-reference.wav');
const driveReference = pathAt('references', 'approved-main-drive-lift-reference.wav');
const afterglowRuntime = pathAt('nightdrive-afterglow-runtime.wav');
const driveLiftMaster = pathAt('nightdrive-drive-lift.wav');
const afterglowLoop = pathAt('loops', `afterglow-seed-${approvedAfterglowSeed}-idle-loop.wav`);
const driveLoop = pathAt('loops', `drive-lift-seed-${approvedDriveLiftSeed}-loop.wav`);
const afterglowThree = pathAt('verification', `afterglow-seed-${approvedAfterglowSeed}-idle-loop-3x.wav`);
const driveThree = pathAt('verification', `drive-lift-seed-${approvedDriveLiftSeed}-loop-3x.wav`);
const afterglowAudition = pathAt('auditions', `afterglow-seed-${approvedAfterglowSeed}-handoff-and-seam.wav`);
const driveAudition = pathAt('auditions', `drive-lift-seed-${approvedDriveLiftSeed}-four-moods.wav`);
expectAudio(afterglowReference, 1_344_000);
expectAudio(driveReference, 1_071_628);
expectAudio(afterglowRuntime, 1_205_581);
expectAudio(driveLiftMaster, 535_814);
expectAudio(afterglowLoop, 1_071_628);
expectAudio(driveLoop, 535_814);
expectAudio(afterglowThree, 3_214_884);
expectAudio(driveThree, 1_607_442);
const webDerivatives = await Promise.all([
derivative('afterglow', 'opus', 1_205_581),
derivative('afterglow', 'mp3', 1_205_581),
derivative('driveLift', 'opus', 535_814),
derivative('driveLift', 'mp3', 535_814),
]);
const newAudioBytes = webDerivatives.reduce((sum, item) => sum + item.encoded.bytes, 0);
const deferredAudioBytes = EXISTING_AUDIO_BYTES + newAudioBytes;
if (deferredAudioBytes > DEFERRED_AUDIO_CAP_BYTES) {
throw new Error(`Deferred audio ${deferredAudioBytes} exceeds ${DEFERRED_AUDIO_CAP_BYTES}`);
}
const manifest = {
schema: 'nightdrive-adaptive-production/v1',
musicalContract: { bpm: 86, key: 'F minor', timeSignature: '4/4', sampleRate: 48_000 },
models: { generator: 'acestep-v15-xl-sft', planner: 'acestep-5Hz-lm-4B', cpuOffload: true },
references: [
{
role: 'afterglow-conditioning',
sourceMaster: await fileRecord(APPROVED_MAIN),
sourceSampleRange: [7_133_023, 8_477_023],
excerpt: await fileRecord(afterglowReference),
technicalScreen: await technicalScreen('approved-main-afterglow-reference'),
},
{
role: 'drive-lift-conditioning',
sourceMaster: await fileRecord(APPROVED_MAIN),
sourceSampleRange: [6_429_767, 7_501_395],
excerpt: await fileRecord(driveReference),
technicalScreen: await technicalScreen('approved-main-drive-lift-reference'),
},
],
candidates: await Promise.all([
candidate('afterglow', 44201, approvedAfterglowSeed === 44201),
candidate('afterglow', 44202, approvedAfterglowSeed === 44202),
candidate('driveLift', 44301, approvedDriveLiftSeed === 44301),
candidate('driveLift', 44302, approvedDriveLiftSeed === 44302),
]),
selection: {
approvedByListener: true,
approvedAfterglowSeed,
approvedDriveLiftSeed,
checkpoint: 'Explicit human approval after in-context audition; measurements used only for technical screening.',
},
afterglow: {
transitionSamples: 133_953,
loopStartSample: 133_953,
loopEndSample: 1_205_581,
loopBars: 8,
crossfadeSamples: 12_000,
runtime: await fileRecord(afterglowRuntime),
runtimeProbe: expectAudio(afterglowRuntime, 1_205_581),
runtimeTechnicalScreen: await technicalScreen(`afterglow-seed-${approvedAfterglowSeed}-runtime`),
loop: await fileRecord(afterglowLoop),
loopBuild: await readJson(pathAt('analysis', `afterglow-seed-${approvedAfterglowSeed}-loop-build.json`)),
runtimeBuild: await readJson(pathAt('analysis', `afterglow-seed-${approvedAfterglowSeed}-runtime-build.json`)),
threeRepeatSamples: 3_214_884,
threeRepeat: await fileRecord(afterglowThree),
threeRepeatTechnicalScreen: await technicalScreen(`afterglow-seed-${approvedAfterglowSeed}-idle-loop-3x`),
audition: await fileRecord(afterglowAudition),
},
driveLift: {
loopStartSample: 0,
loopEndSample: 535_814,
loopBars: 4,
crossfadeSamples: 12_000,
master: await fileRecord(driveLiftMaster),
masterProbe: expectAudio(driveLiftMaster, 535_814),
masterTechnicalScreen: await technicalScreen(`drive-lift-seed-${approvedDriveLiftSeed}-loop`),
loop: await fileRecord(driveLoop),
loopBuild: await readJson(pathAt('analysis', `drive-lift-seed-${approvedDriveLiftSeed}-loop-build.json`)),
threeRepeatSamples: 1_607_442,
threeRepeat: await fileRecord(driveThree),
threeRepeatTechnicalScreen: await technicalScreen(`drive-lift-seed-${approvedDriveLiftSeed}-loop-3x`),
audition: await fileRecord(driveAudition),
},
masters: [
{ kind: 'afterglow', file: await fileRecord(afterglowRuntime) },
{ kind: 'driveLift', file: await fileRecord(driveLiftMaster) },
],
webDerivatives,
deliveryBudget: {
existingAudioBytes: EXISTING_AUDIO_BYTES,
newAudioBytes,
deferredAudioBytes,
capBytes: DEFERRED_AUDIO_CAP_BYTES,
},
qualityStatement: 'Measurements establish technical fitness only; musical approval was a human listening decision.',
};
await writeFile(pathAt('production-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
```
Run exactly one command matching the listener-approved pair:
```bash
ADAPTIVE_AFTERGLOW_SEED=44201 ADAPTIVE_DRIVE_SEED=44301 node /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/build-production-manifest.mjs
ADAPTIVE_AFTERGLOW_SEED=44201 ADAPTIVE_DRIVE_SEED=44302 node /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/build-production-manifest.mjs
ADAPTIVE_AFTERGLOW_SEED=44202 ADAPTIVE_DRIVE_SEED=44301 node /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/build-production-manifest.mjs
ADAPTIVE_AFTERGLOW_SEED=44202 ADAPTIVE_DRIVE_SEED=44302 node /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/build-production-manifest.mjs
```
Then validate the assembled record:
```bash
jq -e '
.schema == "nightdrive-adaptive-production/v1" and
.selection.approvedByListener == true and
(.candidates | length) == 4 and
([.candidates[] | select(.disposition == "listener-approved")] | length) == 2 and
.afterglow.runtimeProbe.durationSamples == 1205581 and
.driveLift.masterProbe.durationSamples == 535814 and
.deliveryBudget.deferredAudioBytes <= .deliveryBudget.capBytes
' /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/production-manifest.json
```
Expected: the builder exits zero, writes a fully populated manifest from saved evidence, and `jq` prints `true`. The manifest records all four task IDs through their generation records, both exact prompts, reference source ranges and hashes, candidate dispositions, analyzer/FFmpeg reports, loop seam metrics, three-repeat hashes, canonical master hashes, derivative bytes/hashes/decoded sample counts, and explicit listener approval. Do not begin Task 3 until this gate passes.
### Task 3: Publish the approved adaptive asset contract
**Files:**
- Modify: `src/content/types.ts:136-145`
- Modify: `src/content/schemas.ts:62-65,202-206,313-328`
- Modify: `content/public/portfolio-content.json:248-257`
- Modify: `content/public/asset-manifest.json:3-50`
- Modify: `scripts/materialize-empty-asset-set.mjs:131-146`
- Modify: `scripts/check-bundle-budgets.mjs:70-82`
- Modify: `scripts/lib/bundle-budgets.mjs:1-50`
- Modify: `scripts/lib/bundle-budgets.d.mts:1`
- Create: `inputs/prepared-assets/nightdrive-afterglow.opus`
- Create: `inputs/prepared-assets/nightdrive-afterglow.mp3`
- Create: `inputs/prepared-assets/nightdrive-drive-lift.opus`
- Create: `inputs/prepared-assets/nightdrive-drive-lift.mp3`
- Modify: `tests/unit/content-schemas.test.ts:17-75`
- Modify: `tests/integration/public-content.test.ts:39-52`
- Modify: `tests/integration/materialize-empty-asset-set.test.mjs`
- Modify: `tests/integration/verify-artifacts.test.ts`
- Modify: `tests/performance/budgets.test.ts:31-67,121-136`
**Interfaces:**
- Consumes: the explicit listener approval and exact hashes/durations in `/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/production-manifest.json`.
- Produces: strict `SoundtrackContent { intro, main, afterglow, driveLift }`, eight exact manifest references, four prepared adaptive derivatives, and a 16 MiB aggregate deferred-audio gate.
- [ ] **Step 1: Write the failing content and budget assertions**
Extend the mutable fixture soundtrack type and closure fixture in `tests/unit/content-schemas.test.ts`:
```ts
soundtrack?: {
intro: { opusAssetId: string; mp3AssetId: string };
main: { opusAssetId: string; mp3AssetId: string };
afterglow: { opusAssetId: string; mp3AssetId: string };
driveLift: { opusAssetId: string; mp3AssetId: string };
};
```
Use this exact soundtrack object in the acceptance case:
```ts
content.soundtrack = {
intro: { opusAssetId: 'soundtrack-intro-opus', mp3AssetId: 'soundtrack-intro-mp3' },
main: { opusAssetId: 'soundtrack-main-opus', mp3AssetId: 'soundtrack-main-mp3' },
afterglow: { opusAssetId: 'soundtrack-afterglow-opus', mp3AssetId: 'soundtrack-afterglow-mp3' },
driveLift: { opusAssetId: 'soundtrack-drive-lift-opus', mp3AssetId: 'soundtrack-drive-lift-mp3' },
};
```
Add four fixture asset records with `width: null`, `height: null`, positive `durationMs`, and the exact IDs/filenames above. Extend `publishedReferences` in `tests/integration/public-content.test.ts` with the four new IDs. Add these assertions to `tests/performance/budgets.test.ts`:
```ts
expect(PERFORMANCE_BUDGETS.deferredAudio).toBe(16 * 1024 * 1024);
expect(classifyArtifact('assets/nightdrive-afterglow.opus', '')).toBe('soundtrack-audio');
expect(classifyArtifact('assets/nightdrive-afterglow.mp3', '')).toBe('soundtrack-audio');
expect(classifyArtifact('assets/nightdrive-drive-lift.opus', '')).toBe('soundtrack-audio');
expect(classifyArtifact('assets/nightdrive-drive-lift.mp3', '')).toBe('soundtrack-audio');
```
In the materialization and artifact fixtures, add all four new files and assert that omitting, altering, or adding an unreferenced adaptive file still fails closed.
- [ ] **Step 2: Run the focused contract tests and verify the new assertions fail**
Run:
```bash
npm run test:unit -- tests/unit/content-schemas.test.ts tests/integration/public-content.test.ts tests/integration/materialize-empty-asset-set.test.mjs tests/integration/verify-artifacts.test.ts tests/performance/budgets.test.ts
```
Expected: FAIL because `afterglow` and `driveLift` are not accepted or referenced, the filename classifier rejects them, and the budget is still 13 MiB.
- [ ] **Step 3: Extend the strict typed content boundary**
Replace `SoundtrackContent` in `src/content/types.ts` with:
```ts
export interface SoundtrackContent {
intro: SoundtrackAssetSources;
main: SoundtrackAssetSources;
afterglow: SoundtrackAssetSources;
driveLift: SoundtrackAssetSources;
}
```
Use the same four required fields in the strict Zod object:
```ts
soundtrack: z.object({
intro: soundtrackAssetSourcesSchema,
main: soundtrackAssetSourcesSchema,
afterglow: soundtrackAssetSourcesSchema,
driveLift: soundtrackAssetSourcesSchema,
}).strict().optional(),
```
Extend `parseProductionInputSnapshot` with:
```ts
content.soundtrack.afterglow.opusAssetId,
content.soundtrack.afterglow.mp3AssetId,
content.soundtrack.driveLift.opusAssetId,
content.soundtrack.driveLift.mp3AssetId,
```
In `scripts/materialize-empty-asset-set.mjs`, replace the two-region loop with:
```js
for (const regionName of ['intro', 'main', 'afterglow', 'driveLift']) {
const region = content.soundtrack[regionName];
if (typeof region !== 'object' || region === null || Array.isArray(region)) {
throw new Error(`Portfolio soundtrack ${regionName} must be an object.`);
}
references.push(region.opusAssetId, region.mp3AssetId);
}
```
In `scripts/check-bundle-budgets.mjs`, classify the source pairs through one complete region list:
```js
for (const region of [
content.soundtrack.intro,
content.soundtrack.main,
content.soundtrack.afterglow,
content.soundtrack.driveLift,
]) {
if (region?.opusAssetId) assetClassById.set(region.opusAssetId, 'soundtrack-audio');
if (region?.mp3AssetId) assetClassById.set(region.mp3AssetId, 'soundtrack-audio');
}
```
- [ ] **Step 4: Raise only the approved audio cap and filename classifier**
Use these exact values in `scripts/lib/bundle-budgets.mjs`:
```js
deferredAudio: 16 * 1024 * 1024,
```
```js
if (/^assets\/nightdrive-(?:intro|main|afterglow|drive-lift)\.(?:opus|mp3)$/u.test(path)) {
return 'soundtrack-audio';
}
```
Keep every other budget and classifier unchanged. The declaration file continues exporting the same public `Readonly<Record<string, number>>` contract.
- [ ] **Step 5: Copy the approved binaries and add exact public records**
Run:
```bash
cp /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-afterglow.opus inputs/prepared-assets/nightdrive-afterglow.opus
cp /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-afterglow.mp3 inputs/prepared-assets/nightdrive-afterglow.mp3
cp /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-drive-lift.opus inputs/prepared-assets/nightdrive-drive-lift.opus
cp /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/web/nightdrive-drive-lift.mp3 inputs/prepared-assets/nightdrive-drive-lift.mp3
sha256sum inputs/prepared-assets/nightdrive-afterglow.opus inputs/prepared-assets/nightdrive-afterglow.mp3 inputs/prepared-assets/nightdrive-drive-lift.opus inputs/prepared-assets/nightdrive-drive-lift.mp3
for AUDIO_PATH in inputs/prepared-assets/nightdrive-afterglow.opus inputs/prepared-assets/nightdrive-afterglow.mp3 inputs/prepared-assets/nightdrive-drive-lift.opus inputs/prepared-assets/nightdrive-drive-lift.mp3; do
ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$AUDIO_PATH"
done
```
Add this exact content shape to `content/public/portfolio-content.json`:
```json
"afterglow": {
"opusAssetId": "soundtrack-afterglow-opus",
"mp3AssetId": "soundtrack-afterglow-mp3"
},
"driveLift": {
"opusAssetId": "soundtrack-drive-lift-opus",
"mp3AssetId": "soundtrack-drive-lift-mp3"
}
```
Add four records to `content/public/asset-manifest.json` using IDs and filenames from Step 1, the SHA-256 values printed above, rounded verified durations from FFprobe, `width: null`, `height: null`, and the same rights holder, license, and attribution fields as intro/main. Compare those values against the external production manifest before saving; do not transcribe an inferred digest or duration.
- [ ] **Step 6: Run the focused contract and exact-asset checks**
Run:
```bash
npm run test:unit -- tests/unit/content-schemas.test.ts tests/integration/public-content.test.ts tests/integration/materialize-empty-asset-set.test.mjs tests/integration/verify-artifacts.test.ts tests/performance/budgets.test.ts
npm run verify:approved-assets
```
Expected: tests pass; all eight soundtrack references form an exact manifest closure; all file hashes match. The aggregate deferred-audio total remains below 16,777,216 bytes in the external byte precheck and is checked against the compiled `dist/assets` in Task 10.
- [ ] **Step 7: Commit the adaptive asset contract**
Run:
```bash
git add src/content/types.ts src/content/schemas.ts content/public/portfolio-content.json content/public/asset-manifest.json
git add scripts/materialize-empty-asset-set.mjs scripts/check-bundle-budgets.mjs scripts/lib/bundle-budgets.mjs scripts/lib/bundle-budgets.d.mts
git add inputs/prepared-assets/nightdrive-afterglow.opus inputs/prepared-assets/nightdrive-afterglow.mp3 inputs/prepared-assets/nightdrive-drive-lift.opus inputs/prepared-assets/nightdrive-drive-lift.mp3
git add tests/unit/content-schemas.test.ts tests/integration/public-content.test.ts tests/integration/materialize-empty-asset-set.test.mjs tests/integration/verify-artifacts.test.ts tests/performance/budgets.test.ts
git commit -m "feat: add adaptive soundtrack layer assets"
```
Expected: the commit contains only the approved additions and their delivery contract.
### Task 4: Expose stable navigation and section geometry signals
**Files:**
- Modify: `src/journey/navigation-controller.ts:8-13,122-224,278-352`
- Modify: `src/journey/types.ts:69-74`
- Modify: `src/journey/section-controller.ts:1-65`
- Modify: `src/composables/useJourneySections.ts:19-176`
- Modify: `src/components/AppShell.vue:62-67`
- Modify: `tests/unit/navigation-controller.test.ts:22-311`
- Modify: `tests/unit/section-controller.test.ts`
**Interfaces:**
- Consumes: canonical section IDs, existing navigation epochs, the existing single `useScrollDrive` frame clock, cached heading document positions, and responsive `ResizeObserver` invalidation.
- Produces: `NavigationState.phase`, `SectionActivationSnapshot`, and `JourneySectionsBinding`, which Task 5 consumes without adding a second scroll listener or DOM measurement pass.
Define these exact public contracts:
```ts
export type NavigationPhase = 'idle' | 'travelling' | 'settled';
export interface NavigationState {
readonly epoch: number;
readonly source: JourneySource;
readonly phase: NavigationPhase;
readonly destinationId: SectionId | null;
readonly signDestinationId: SectionId | null;
}
```
```ts
export interface SectionActivationSnapshot {
readonly layoutRevision: number;
readonly activeHeadingOffsetVh: number;
readonly nextHeadingOffsetVh: number | null;
}
export interface JourneySnapshot extends ScrollDriveSnapshot {
readonly activeSectionId: SectionId;
readonly chapter: EnvironmentChapter;
readonly sign: SignSnapshot | null;
readonly arrived: boolean;
readonly sectionActivation: SectionActivationSnapshot;
}
```
```ts
export interface JourneySectionsBinding {
readonly snapshot: Readonly<Ref<JourneySnapshot>>;
requestFrame(): void;
subscribeFrame(
listener: (now: number, snapshot: JourneySnapshot) => boolean,
): () => void;
}
```
- [ ] **Step 1: Write failing navigation-phase tests**
Update existing expected states with `phase`. Add a latest-epoch settlement test:
```ts
it('publishes travelling and settled only for the latest explicit epoch', () => {
const harness = createRuntime(true);
const controller = createNavigationController(sectionIds, harness.runtime);
const states: NavigationState[] = [];
controller.subscribe((state) => states.push(state));
controller.navigate('contact', target('contact'));
controller.navigate('toolkit', target('toolkit'));
expect(controller.state()).toMatchObject({
epoch: 2,
source: 'explicit-navigation',
phase: 'travelling',
destinationId: 'toolkit',
});
harness.emitScrollEnd();
expect(controller.state()).toMatchObject({
epoch: 2,
phase: 'settled',
destinationId: 'toolkit',
});
expect(states.filter((state) => state.phase === 'settled')).toHaveLength(1);
});
```
Also assert that `noteUserScrollIntent()` produces `phase: 'idle'`, `initialize()` settles after its instant alignment, and `restore()` remains `travelling` until its guarded frame performs instant alignment and then emits `settled`.
- [ ] **Step 2: Write failing activation-geometry tests**
Add this case to `tests/unit/section-controller.test.ts`:
```ts
it('derives responsive heading offsets from the same activation-line geometry', () => {
expect(deriveSectionActivationSnapshot({
headings: [
{ id: 'start', top: 100 },
{ id: 'profile', top: 420 },
{ id: 'experience', top: 1_120 },
],
activeSectionId: 'profile',
activationY: 500,
viewportHeight: 800,
layoutRevision: 7,
})).toEqual({
layoutRevision: 7,
activeHeadingOffsetVh: -0.1,
nextHeadingOffsetVh: 0.775,
});
});
```
Add a second assertion for the final heading returning `nextHeadingOffsetVh: null`.
- [ ] **Step 3: Run the focused journey tests and verify they fail**
Run:
```bash
npm run test:unit -- tests/unit/navigation-controller.test.ts tests/unit/section-controller.test.ts tests/unit/scroll-drive-controller.test.ts
```
Expected: FAIL because phase and normalized activation geometry do not exist.
- [ ] **Step 4: Publish guarded navigation settlement**
Initialize navigation with an idle phase:
```ts
let currentState: NavigationState = {
epoch: 0,
source: 'initial',
phase: 'idle',
destinationId: null,
signDestinationId: null,
};
```
Have `beginEpoch` set phase solely from source:
```ts
currentState = {
epoch: currentState.epoch + 1,
source,
phase: source === 'passive-scroll' ? 'idle' : 'travelling',
destinationId,
signDestinationId,
};
```
Inside the existing epoch/source/destination guards, settle before scheduling focus:
```ts
currentState = { ...currentState, phase: 'settled' };
emit();
frameId = runtime.requestFrame(() => {
frameId = undefined;
if (
currentState.epoch === epoch &&
currentState.source === 'explicit-navigation' &&
currentState.destinationId === id
) {
runtime.focusHeading(target);
}
});
```
For `initial`, perform the instant scroll, set the same guarded epoch to `settled`, and emit synchronously. For `history-restoration`, keep `travelling` until its existing frame performs the instant scroll; then set `settled` and emit without focusing. An older frame or `scrollend` must never settle a newer epoch.
- [ ] **Step 5: Derive activation offsets as pure geometry**
Add to `src/journey/section-controller.ts`:
```ts
export interface SectionActivationInput {
readonly headings: readonly SectionHeadingGeometry[];
readonly activeSectionId: SectionId;
readonly activationY: number;
readonly viewportHeight: number;
readonly layoutRevision: number;
}
export function deriveSectionActivationSnapshot(
input: SectionActivationInput,
): SectionActivationSnapshot {
const activeIndex = input.headings.findIndex(
(heading) => heading.id === input.activeSectionId,
);
const activeHeading = input.headings[activeIndex];
if (activeHeading === undefined || input.viewportHeight <= 0) {
return {
layoutRevision: input.layoutRevision,
activeHeadingOffsetVh: 0,
nextHeadingOffsetVh: null,
};
}
const nextHeading = input.headings[activeIndex + 1];
return {
layoutRevision: input.layoutRevision,
activeHeadingOffsetVh:
(activeHeading.top - input.activationY) / input.viewportHeight,
nextHeadingOffsetVh: nextHeading === undefined
? null
: (nextHeading.top - input.activationY) / input.viewportHeight,
};
}
```
Import `SectionActivationSnapshot` from `src/journey/types.ts`.
- [ ] **Step 6: Expose the post-measurement shared-frame binding**
In `useJourneySections`, add `sectionActivation` to the initial snapshot, a monotonically increasing `layoutRevision`, and a local subscriber set:
```ts
const frameSubscribers = new Set<(
now: number,
snapshot: JourneySnapshot,
) => boolean>();
let layoutRevision = 0;
if (structureDirty) {
headings = readHeadings(driveSnapshot.scrollY);
layoutRevision += 1;
structureDirty = false;
}
```
Derive and publish geometry in the same measurement that selects the visual section:
```ts
const sectionActivation = deriveSectionActivationSnapshot({
headings,
activeSectionId,
activationY,
viewportHeight,
layoutRevision,
});
sectionSnapshot.value = {
...driveSnapshot,
routeProgress: atDocumentEnd ? 1 : driveSnapshot.routeProgress,
...sectionState,
sectionActivation,
sign,
};
let keepRunning = signController.needsFrame();
for (const subscriber of [...frameSubscribers]) {
keepRunning = subscriber(now, snapshot.value) || keepRunning;
}
return keepRunning;
```
Return the complete binding and clear subscribers at teardown:
```ts
return {
snapshot: readonly(snapshot),
requestFrame: () => requestJourneyFrame(),
subscribeFrame(listener) {
frameSubscribers.add(listener);
return () => frameSubscribers.delete(listener);
},
};
```
Do not add a scroll listener or another call to `getBoundingClientRect()`.
- [ ] **Step 7: Adapt the current caller without changing audio behavior yet**
In `AppShell.vue`, retain a ref named `journey` while exposing the binding for Task 5:
```ts
const journeyBinding = useJourneySections(
canonicalSectionIds,
navigation.state,
computed(() => motion.policy.value.allowSceneRequest),
);
const journey = journeyBinding.snapshot;
```
All existing `journey.value` consumers stay unchanged in this task.
- [ ] **Step 8: Run focused tests and typecheck**
Run:
```bash
npm run test:unit -- tests/unit/navigation-controller.test.ts tests/unit/section-controller.test.ts tests/unit/scroll-drive-controller.test.ts
npm run typecheck
```
Expected: all checks pass, one raw scroll listener remains covered by `scroll-drive-controller.test.ts`, and responsive geometry is carried in the shared snapshot.
- [ ] **Step 9: Commit the stable journey signals**
Run:
```bash
git add src/journey/navigation-controller.ts src/journey/types.ts src/journey/section-controller.ts src/composables/useJourneySections.ts src/components/AppShell.vue tests/unit/navigation-controller.test.ts tests/unit/section-controller.test.ts
git commit -m "feat: expose stable journey intent signals"
```
Expected: one independently passing journey-signal commit.
### Task 5: Implement the pure audio-intent coordinator
**Files:**
- Modify: `src/audio/types.ts:9-23`
- Modify: `src/audio/journey-music-state.ts`
- Create: `src/audio/audio-intent-coordinator.ts`
- Create: `src/composables/useAudioIntent.ts`
- Create: `tests/unit/audio-intent-coordinator.test.ts`
- Create: `tests/unit/use-audio-intent.test.ts`
**Interfaces:**
- Consumes: `JourneySnapshot.sectionActivation`, `NavigationState.phase`, canonical section order, `performance.now()` timestamps, visibility, and existing normalized scroll acceleration.
- Produces: stable `ChapterIntent`, rate-limited `DriveEnergySample`, and `needsFrame`; Task 6 sends these through independent controller commands.
Add these shared types without removing `AudioJourneyState` until Task 6:
```ts
export type MusicMood = 'open' | 'shadow' | 'lift' | 'resolve';
export type MusicChapterBias = MusicMood;
export type DriveEnergySource = 'physical' | 'navigation' | 'idle';
export interface ChapterIntent {
readonly sectionId: SectionId;
readonly mood: MusicMood;
readonly atStart: boolean;
readonly entryPhrase: number;
readonly generation: number;
}
export interface DriveEnergySample {
readonly energy: number;
readonly source: DriveEnergySource;
}
export interface AudioInitialState {
readonly chapter: ChapterIntent;
readonly drive: DriveEnergySample;
}
```
- [ ] **Step 1: Write the coordinator's high-value failing tests**
Create `tests/unit/audio-intent-coordinator.test.ts` with a complete default sample helper:
```ts
const sectionIds = [
'start', 'profile', 'experience', 'infrastructure',
'selected-builds', 'additional-work', 'toolkit', 'contact',
] as const satisfies readonly SectionId[];
const baseSample = (
overrides: Partial<AudioIntentSample> = {},
): AudioIntentSample => ({
now: 0,
visible: true,
scrollY: 0,
activeSectionId: 'start',
acceleration: 0,
sectionActivation: {
layoutRevision: 1,
activeHeadingOffsetVh: -0.1,
nextHeadingOffsetVh: 0.8,
},
navigation: {
epoch: 1,
source: 'passive-scroll',
phase: 'idle',
destinationId: null,
signDestinationId: null,
},
...overrides,
});
```
Cover passive dwell and reversal:
```ts
it('commits once only after 6vh hysteresis and 400 ms dwell', () => {
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
expect(coordinator.sample(baseSample({
now: 10,
scrollY: 10,
activeSectionId: 'profile',
sectionActivation: {
layoutRevision: 1,
activeHeadingOffsetVh: -0.059,
nextHeadingOffsetVh: 0.8,
},
})).toMatchObject({ chapter: null, needsFrame: false });
expect(coordinator.sample(baseSample({ now: 100, scrollY: 10, activeSectionId: 'profile' })))
.toMatchObject({ chapter: null, needsFrame: true });
expect(coordinator.sample(baseSample({ now: 499, scrollY: 10, activeSectionId: 'profile' }))).toMatchObject({ chapter: null });
expect(coordinator.sample(baseSample({ now: 500, scrollY: 10, activeSectionId: 'profile' })).chapter)
.toMatchObject({ sectionId: 'profile', mood: 'open', generation: 1 });
expect(coordinator.sample(baseSample({ now: 900, scrollY: 10, activeSectionId: 'profile' })).chapter).toBeNull();
});
it('clears a passive candidate on reversal or structural remeasurement', () => {
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
coordinator.sample(baseSample({ now: 0, scrollY: 10, activeSectionId: 'profile' }));
expect(coordinator.sample(baseSample({ now: 250, scrollY: 9, activeSectionId: 'start' })).needsFrame).toBe(false);
coordinator.sample(baseSample({ now: 300, scrollY: 12, activeSectionId: 'profile' }));
expect(coordinator.sample(baseSample({
now: 800,
scrollY: 12,
activeSectionId: 'profile',
sectionActivation: {
layoutRevision: 2,
activeHeadingOffsetVh: -0.2,
nextHeadingOffsetVh: 0.7,
},
})).toMatchObject({ chapter: null, needsFrame: false });
});
```
Cover rapid explicit navigation and drive classification:
```ts
it('commits only the latest settled explicit destination', () => {
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
coordinator.sample(baseSample({
navigation: { epoch: 2, source: 'explicit-navigation', phase: 'travelling', destinationId: 'contact', signDestinationId: null },
}));
coordinator.sample(baseSample({
activeSectionId: 'toolkit',
navigation: { epoch: 3, source: 'explicit-navigation', phase: 'travelling', destinationId: 'toolkit', signDestinationId: null },
}));
expect(coordinator.sample(baseSample({
now: 600,
activeSectionId: 'toolkit',
navigation: { epoch: 3, source: 'explicit-navigation', phase: 'settled', destinationId: 'toolkit', signDestinationId: null },
})).toMatchObject({
chapter: { sectionId: 'toolkit', mood: 'resolve', generation: 1 },
});
});
it('dead-bands tiny input and caps programmatic travel below lift entry', () => {
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
expect(coordinator.sample(baseSample({ now: 0, acceleration: 0.07 })).drive).toBeNull();
expect(coordinator.sample(baseSample({ now: 40, acceleration: 0.7 })).drive)
.toEqual({ energy: expect.closeTo((0.7 - 0.08) / 0.92), source: 'physical' });
expect(coordinator.sample(baseSample({
now: 80,
acceleration: 1,
navigation: { epoch: 2, source: 'explicit-navigation', phase: 'travelling', destinationId: 'contact', signDestinationId: null },
})).drive).toEqual({ energy: 0.35, source: 'navigation' });
expect(coordinator.sample(baseSample({ now: 120, acceleration: 0 })).drive)
.toEqual({ energy: 0, source: 'idle' });
});
```
Add one rate-limit case showing that sub-`0.02` changes within 33 ms do not emit, while `0.32`, `0.62`, source, and final-zero crossings emit immediately. Add these layout/visibility cases:
```ts
it('cannot turn a layout-only active-section shift into a passive commit', () => {
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
expect(coordinator.sample(baseSample({
now: 100,
activeSectionId: 'profile',
sectionActivation: { layoutRevision: 2, activeHeadingOffsetVh: -0.2, nextHeadingOffsetVh: 0.7 },
}))).toMatchObject({ chapter: null, needsFrame: false });
expect(coordinator.sample(baseSample({
now: 600,
activeSectionId: 'profile',
sectionActivation: { layoutRevision: 2, activeHeadingOffsetVh: -0.2, nextHeadingOffsetVh: 0.7 },
})).chapter).toBeNull();
expect(coordinator.sample(baseSample({
now: 650,
scrollY: 2,
activeSectionId: 'profile',
sectionActivation: { layoutRevision: 2, activeHeadingOffsetVh: -0.2, nextHeadingOffsetVh: 0.7 },
})).needsFrame).toBe(true);
expect(coordinator.sample(baseSample({
now: 1_050,
scrollY: 2,
activeSectionId: 'profile',
sectionActivation: { layoutRevision: 2, activeHeadingOffsetVh: -0.2, nextHeadingOffsetVh: 0.7 },
})).chapter).toMatchObject({ sectionId: 'profile' });
});
it('defers a genuine settlement across remeasurement but ignores old settled state', () => {
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
coordinator.sample(baseSample({
navigation: { epoch: 2, source: 'explicit-navigation', phase: 'travelling', destinationId: 'contact', signDestinationId: null },
}));
expect(coordinator.sample(baseSample({
now: 100,
activeSectionId: 'contact',
sectionActivation: { layoutRevision: 2, activeHeadingOffsetVh: -0.2, nextHeadingOffsetVh: null },
navigation: { epoch: 2, source: 'explicit-navigation', phase: 'settled', destinationId: 'contact', signDestinationId: null },
}))).toMatchObject({ chapter: null, needsFrame: true });
expect(coordinator.sample(baseSample({
now: 116,
activeSectionId: 'contact',
sectionActivation: { layoutRevision: 2, activeHeadingOffsetVh: -0.2, nextHeadingOffsetVh: null },
navigation: { epoch: 2, source: 'explicit-navigation', phase: 'settled', destinationId: 'contact', signDestinationId: null },
})).chapter).toMatchObject({ sectionId: 'contact' });
});
it('starts one final dwell after wheel events advance passive epochs', () => {
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
const passive = (epoch: number) => ({
epoch,
source: 'passive-scroll' as const,
phase: 'idle' as const,
destinationId: null,
signDestinationId: null,
});
coordinator.sample(baseSample({
now: 100,
scrollY: 24,
activeSectionId: 'profile',
navigation: passive(2),
}));
expect(coordinator.sample(baseSample({
now: 180,
scrollY: 48,
activeSectionId: 'profile',
navigation: passive(3),
})).needsFrame).toBe(true);
expect(coordinator.sample(baseSample({
now: 580,
scrollY: 48,
activeSectionId: 'profile',
navigation: passive(3),
})).chapter).toMatchObject({ sectionId: 'profile' });
});
it('suppresses a navigation that settled while hidden', () => {
const coordinator = createAudioIntentCoordinator(sectionIds, 'start', 1, 0);
const settled = {
epoch: 2,
source: 'explicit-navigation',
phase: 'settled',
destinationId: 'contact',
signDestinationId: null,
} as const;
coordinator.reset({
navigation: settled,
scrollY: 0,
sectionActivation: baseSample().sectionActivation,
});
expect(coordinator.sample(baseSample({
now: 1_000,
activeSectionId: 'contact',
navigation: settled,
})).chapter).toBeNull();
});
```
Create `tests/unit/use-audio-intent.test.ts` with a tiny mounted binding and this direct-load assertion:
```ts
it('seeds the first measured deep-link section before audio activation', () => {
const snapshot = shallowRef<JourneySnapshot>({
scrollY: 7_200,
routeProgress: 1,
direction: 0,
acceleration: 0,
driveSpeed: 0.18,
activeSectionId: 'contact',
chapter: 'destination',
sign: null,
arrived: true,
sectionActivation: {
layoutRevision: 4,
activeHeadingOffsetVh: -0.2,
nextHeadingOffsetVh: null,
},
});
const navigation = shallowRef<NavigationState>({
epoch: 1,
source: 'initial',
phase: 'settled',
destinationId: 'contact',
signDestinationId: null,
});
let frame: ((now: number, value: JourneySnapshot) => boolean) | null = null;
const journey: JourneySectionsBinding = {
snapshot: readonly(snapshot),
requestFrame: vi.fn(),
subscribeFrame(listener) {
frame = listener;
return () => { frame = null; };
},
};
let binding: AudioIntentBinding | null = null;
const wrapper = mount(defineComponent({
setup() {
binding = useAudioIntent({ canonicalIds: sectionIds, journey, navigation });
return () => h('div');
},
}));
expect(frame).not.toBeNull();
frame!(16, snapshot.value);
expect(binding!.chapter.value).toEqual(resolveChapterIntent('contact'));
wrapper.unmount();
});
```
- [ ] **Step 2: Run the coordinator test and verify it fails**
Run:
```bash
npm run test:unit -- tests/unit/audio-intent-coordinator.test.ts tests/unit/use-audio-intent.test.ts
```
Expected: FAIL because the coordinator, chapter type, and drive type do not exist.
- [ ] **Step 3: Make the section-to-music mapping typed and deterministic**
Replace the loose string mapping in `journey-music-state.ts` with:
```ts
const JOURNEY_MUSIC = Object.freeze({
start: { mood: 'open', entryPhrase: 0 },
profile: { mood: 'open', entryPhrase: 1 },
experience: { mood: 'shadow', entryPhrase: 2 },
infrastructure: { mood: 'shadow', entryPhrase: 3 },
'selected-builds': { mood: 'lift', entryPhrase: 4 },
'additional-work': { mood: 'lift', entryPhrase: 5 },
toolkit: { mood: 'resolve', entryPhrase: 6 },
contact: { mood: 'resolve', entryPhrase: 7 },
} as const satisfies Readonly<Record<
SectionId,
{ readonly mood: MusicMood; readonly entryPhrase: number }
>>);
export function resolveChapterIntent(
sectionId: SectionId,
generation = 0,
): ChapterIntent {
const chapter = JOURNEY_MUSIC[sectionId];
return Object.freeze({
sectionId,
mood: chapter.mood,
atStart: sectionId === 'start',
entryPhrase: chapter.entryPhrase,
generation: Math.max(0, Math.trunc(generation)),
});
}
export const DEFAULT_CHAPTER_INTENT = resolveChapterIntent('start');
export const DEFAULT_DRIVE_ENERGY = Object.freeze({
energy: 0,
source: 'idle',
} as const satisfies DriveEnergySample);
```
Keep `resolveJourneyMusicState()` as a short compatibility adapter through Task 5 so the existing engine continues compiling; Task 6 removes it.
- [ ] **Step 4: Implement the pure coordinator**
Use these exact constants and public surface in `audio-intent-coordinator.ts`:
```ts
const PASSIVE_HYSTERESIS_VH = 0.06;
const PASSIVE_DWELL_MS = 400;
const DRIVE_DEAD_ZONE = 0.08;
const NAVIGATION_DRIVE_CAP = 0.35;
const DRIVE_EMIT_INTERVAL_MS = 33;
const DRIVE_EMIT_DELTA = 0.02;
const DRIVE_LIFT_EXIT = 0.32;
const DRIVE_LIFT_ENTRY = 0.62;
export interface AudioIntentSample {
readonly now: number;
readonly visible: boolean;
readonly scrollY: number;
readonly activeSectionId: SectionId;
readonly acceleration: number;
readonly sectionActivation: SectionActivationSnapshot;
readonly navigation: NavigationState;
}
export interface AudioIntentUpdate {
readonly chapter: ChapterIntent | null;
readonly drive: DriveEnergySample | null;
readonly needsFrame: boolean;
}
export interface AudioIntentCoordinator {
current(): AudioInitialState;
sample(input: AudioIntentSample): AudioIntentUpdate;
reset(input: Pick<
AudioIntentSample,
'navigation' | 'scrollY' | 'sectionActivation'
>): AudioIntentUpdate;
}
```
Implement state and commitment with no timers or DOM access:
```ts
export function createAudioIntentCoordinator(
canonicalIds: readonly SectionId[],
initialSectionId: SectionId,
initialLayoutRevision: number,
initialScrollY: number,
): AudioIntentCoordinator {
const indexById = new Map(canonicalIds.map((id, index) => [id, index]));
if (!indexById.has(initialSectionId)) {
throw new Error(`Unknown initial audio section: ${initialSectionId}`);
}
let committedId = initialSectionId;
let generation = 0;
let pendingId: SectionId | null = null;
let pendingSince = 0;
let layoutRevision = initialLayoutRevision;
let scrollYAtLayout = initialScrollY;
let movedSinceLayout = false;
let navigationEpoch = -1;
let navigationPhase: NavigationPhase = 'idle';
let suppressedSettledEpoch = -1;
let deferredSettlement: {
readonly epoch: number;
readonly destinationId: SectionId;
readonly layoutRevision: number;
} | null = null;
let drive: DriveEnergySample = DEFAULT_DRIVE_ENERGY;
let driveEmittedAt = Number.NEGATIVE_INFINITY;
const clearCandidate = (): void => {
pendingId = null;
pendingSince = 0;
};
const commit = (id: SectionId): ChapterIntent | null => {
if (id === committedId) return null;
committedId = id;
generation += 1;
clearCandidate();
return resolveChapterIntent(id, generation);
};
const current = (): AudioInitialState => ({
chapter: resolveChapterIntent(committedId, generation),
drive,
});
```
Classify and throttle drive independently:
```ts
const classifyDrive = (input: AudioIntentSample): DriveEnergySample => {
const acceleration = Math.min(1, Math.max(0, input.acceleration));
if (!input.visible) return DEFAULT_DRIVE_ENERGY;
if (input.navigation.phase === 'travelling') {
return acceleration === 0
? DEFAULT_DRIVE_ENERGY
: { energy: Math.min(NAVIGATION_DRIVE_CAP, acceleration), source: 'navigation' };
}
if (input.navigation.phase !== 'idle' || acceleration <= DRIVE_DEAD_ZONE) {
return DEFAULT_DRIVE_ENERGY;
}
return {
energy: (acceleration - DRIVE_DEAD_ZONE) / (1 - DRIVE_DEAD_ZONE),
source: 'physical',
};
};
const crossed = (previous: number, next: number, threshold: number): boolean =>
(previous < threshold && next >= threshold) ||
(previous >= threshold && next < threshold);
const emitDrive = (
next: DriveEnergySample,
now: number,
): DriveEnergySample | null => {
const urgent = next.source !== drive.source ||
(next.energy === 0) !== (drive.energy === 0) ||
crossed(drive.energy, next.energy, DRIVE_LIFT_EXIT) ||
crossed(drive.energy, next.energy, DRIVE_LIFT_ENTRY);
const due = now - driveEmittedAt >= DRIVE_EMIT_INTERVAL_MS &&
Math.abs(next.energy - drive.energy) >= DRIVE_EMIT_DELTA;
if (!urgent && !due) return null;
drive = Object.freeze(next);
driveEmittedAt = now;
return drive;
};
```
Complete `sample()` with edge-triggered settlement, a one-frame measurement-order bridge, and a movement requirement after every layout revision. This lets a real settlement survive a same-frame remeasurement while preventing a stale `settled` snapshot or a resize-only active-section shift from committing:
```ts
const suppressCurrentPosition = (input: Pick<
AudioIntentSample,
'navigation' | 'scrollY' | 'sectionActivation'
>): void => {
clearCandidate();
deferredSettlement = null;
layoutRevision = input.sectionActivation.layoutRevision;
scrollYAtLayout = input.scrollY;
movedSinceLayout = false;
navigationEpoch = input.navigation.epoch;
navigationPhase = input.navigation.phase;
suppressedSettledEpoch = input.navigation.epoch;
};
const sample = (input: AudioIntentSample): AudioIntentUpdate => {
let chapter: ChapterIntent | null = null;
const nextDrive = emitDrive(classifyDrive(input), input.now);
if (!input.visible) {
suppressCurrentPosition(input);
return { chapter: null, drive: nextDrive, needsFrame: false };
}
const layoutChanged = input.sectionActivation.layoutRevision !== layoutRevision;
if (layoutChanged) {
layoutRevision = input.sectionActivation.layoutRevision;
scrollYAtLayout = input.scrollY;
movedSinceLayout = false;
clearCandidate();
deferredSettlement = null;
}
if (Math.abs(input.scrollY - scrollYAtLayout) >= 1) movedSinceLayout = true;
const navigationChanged = input.navigation.epoch !== navigationEpoch;
const settledEdge = input.navigation.phase === 'settled' &&
(navigationChanged || navigationPhase !== 'settled');
if (navigationChanged) {
navigationEpoch = input.navigation.epoch;
clearCandidate();
deferredSettlement = null;
}
navigationPhase = input.navigation.phase;
if (input.navigation.phase === 'travelling') {
return { chapter: null, drive: nextDrive, needsFrame: false };
}
const destination = input.navigation.destinationId;
if (layoutChanged) {
if (
settledEdge &&
input.navigation.epoch !== suppressedSettledEpoch &&
destination !== null
) {
deferredSettlement = {
epoch: input.navigation.epoch,
destinationId: destination,
layoutRevision,
};
return { chapter: null, drive: nextDrive, needsFrame: true };
}
return { chapter: null, drive: nextDrive, needsFrame: false };
}
const deferred = deferredSettlement;
if (deferred !== null) {
deferredSettlement = null;
const stillValid = input.navigation.phase === 'settled' &&
input.navigation.epoch === deferred.epoch &&
input.navigation.epoch !== suppressedSettledEpoch &&
input.navigation.destinationId === deferred.destinationId &&
input.sectionActivation.layoutRevision === deferred.layoutRevision &&
input.activeSectionId === deferred.destinationId;
chapter = stillValid ? commit(deferred.destinationId) : null;
return { chapter, drive: nextDrive, needsFrame: false };
}
if (input.navigation.phase === 'settled') {
if (
settledEdge &&
input.navigation.epoch !== suppressedSettledEpoch &&
destination !== null &&
destination === input.activeSectionId
) {
chapter = commit(destination);
} else if (
settledEdge &&
input.navigation.epoch !== suppressedSettledEpoch &&
destination !== null
) {
deferredSettlement = {
epoch: input.navigation.epoch,
destinationId: destination,
layoutRevision,
};
return { chapter: null, drive: nextDrive, needsFrame: true };
}
return { chapter, drive: nextDrive, needsFrame: false };
}
if (!movedSinceLayout || input.activeSectionId === committedId) {
clearCandidate();
return { chapter: null, drive: nextDrive, needsFrame: false };
}
const committedIndex = indexById.get(committedId);
const candidateIndex = indexById.get(input.activeSectionId);
if (committedIndex === undefined || candidateIndex === undefined) {
throw new Error('Audio intent received a non-canonical section.');
}
const eligible = candidateIndex > committedIndex
? input.sectionActivation.activeHeadingOffsetVh <= -PASSIVE_HYSTERESIS_VH
: input.sectionActivation.nextHeadingOffsetVh !== null &&
input.sectionActivation.nextHeadingOffsetVh >= PASSIVE_HYSTERESIS_VH;
if (!eligible) {
clearCandidate();
return { chapter: null, drive: nextDrive, needsFrame: false };
}
if (pendingId !== input.activeSectionId) {
pendingId = input.activeSectionId;
pendingSince = input.now;
return { chapter: null, drive: nextDrive, needsFrame: true };
}
if (input.now - pendingSince < PASSIVE_DWELL_MS) {
return { chapter: null, drive: nextDrive, needsFrame: true };
}
chapter = commit(input.activeSectionId);
return { chapter, drive: nextDrive, needsFrame: false };
};
return {
current,
sample,
reset(input) {
suppressCurrentPosition(input);
const nextDrive = drive.energy === 0 && drive.source === 'idle'
? null
: DEFAULT_DRIVE_ENERGY;
drive = DEFAULT_DRIVE_ENERGY;
driveEmittedAt = Number.NEGATIVE_INFINITY;
return { chapter: null, drive: nextDrive, needsFrame: false };
},
};
}
```
- [ ] **Step 5: Bridge the coordinator to Vue's shared journey frame**
Create `useAudioIntent.ts` with this contract:
```ts
export interface AudioIntentSources {
readonly canonicalIds: readonly SectionId[];
readonly journey: JourneySectionsBinding;
readonly navigation: Readonly<Ref<NavigationState>>;
}
export interface AudioIntentBinding {
readonly chapter: Readonly<Ref<ChapterIntent>>;
readonly drive: Readonly<Ref<DriveEnergySample>>;
}
```
Expose a safe Start fallback during hydration, then seed the coordinator from the first measured journey snapshot before processing deltas. This makes a direct `#contact` load select Contact rather than replaying Start state. Subscribe after journey measurement and request the same frame clock during dwell:
```ts
export function useAudioIntent(sources: AudioIntentSources): AudioIntentBinding {
const firstId = sources.canonicalIds[0];
if (firstId === undefined) throw new Error('Audio intent requires one section.');
const chapter = shallowRef(resolveChapterIntent(firstId));
const drive = shallowRef(DEFAULT_DRIVE_ENERGY);
let coordinator: AudioIntentCoordinator | null = null;
let removeFrame = (): void => {};
const publish = (update: AudioIntentUpdate): boolean => {
if (update.chapter !== null) chapter.value = update.chapter;
if (update.drive !== null) drive.value = update.drive;
return update.needsFrame;
};
const sample = (now: number, snapshot: JourneySnapshot): boolean => {
if (coordinator === null) {
coordinator = createAudioIntentCoordinator(
sources.canonicalIds,
snapshot.activeSectionId,
snapshot.sectionActivation.layoutRevision,
snapshot.scrollY,
);
const seeded = coordinator.current();
chapter.value = seeded.chapter;
drive.value = seeded.drive;
}
return publish(coordinator.sample({
now,
visible: document.visibilityState !== 'hidden',
scrollY: snapshot.scrollY,
activeSectionId: snapshot.activeSectionId,
acceleration: snapshot.acceleration,
sectionActivation: snapshot.sectionActivation,
navigation: sources.navigation.value,
}));
};
const stopNavigationWatch = watch(
sources.navigation,
() => sources.journey.requestFrame(),
{ flush: 'sync' },
);
onMounted(() => {
removeFrame = sources.journey.subscribeFrame(sample);
const resetFromCurrentSnapshot = (): void => {
if (coordinator === null) return;
const snapshot = sources.journey.snapshot.value;
publish(coordinator.reset({
navigation: sources.navigation.value,
scrollY: snapshot.scrollY,
sectionActivation: snapshot.sectionActivation,
}));
};
const onVisibility = (): void => {
resetFromCurrentSnapshot();
if (document.visibilityState !== 'hidden') sources.journey.requestFrame();
};
document.addEventListener('visibilitychange', onVisibility);
removeFrame = combineCleanup(removeFrame, () =>
document.removeEventListener('visibilitychange', onVisibility));
sources.journey.requestFrame();
});
onUnmounted(() => {
stopNavigationWatch();
removeFrame();
});
return { chapter: readonly(chapter), drive: readonly(drive) };
}
function combineCleanup(left: () => void, right: () => void): () => void {
return () => { left(); right(); };
}
```
Use Vue imports for `onMounted`, `onUnmounted`, `readonly`, `shallowRef`, `watch`, and `Ref`. This module performs no DOM measurement and creates no timer.
- [ ] **Step 6: Run the focused coordinator and journey checks**
Run:
```bash
npm run test:unit -- tests/unit/audio-intent-coordinator.test.ts tests/unit/use-audio-intent.test.ts tests/unit/navigation-controller.test.ts tests/unit/section-controller.test.ts
npm run typecheck
```
Expected: all checks pass; no existing visual section behavior changes; coordinator emissions are stable and deterministic.
- [ ] **Step 7: Commit the coordinator**
Run:
```bash
git add src/audio/types.ts src/audio/journey-music-state.ts src/audio/audio-intent-coordinator.ts src/composables/useAudioIntent.ts tests/unit/audio-intent-coordinator.test.ts tests/unit/use-audio-intent.test.ts
git commit -m "feat: coordinate adaptive audio intent"
```
Expected: one pure state-management commit with no Web Audio or asset changes.
### Task 6: Split controller commands and wire stable intent into the app
**Files:**
- Modify: `src/audio/types.ts:9-65`
- Modify: `src/audio/create-audio-controller.ts:1-300`
- Modify: `src/audio/journey-music-state.ts`
- Modify: `src/audio/soundtrack-transport.ts:1-38`
- Modify: `src/audio/soundtrack-engine.ts:1-335`
- Modify: `src/composables/useAudioEngine.ts:1-104`
- Modify: `src/components/AppShell.vue:1-111`
- Modify: `tests/unit/audio-lifecycle.test.ts:1-286`
- Modify: `tests/unit/soundtrack-transport.test.ts:1-51`
**Interfaces:**
- Consumes: `AudioIntentBinding.chapter`, `AudioIntentBinding.drive`, the four-region soundtrack content contract, and the existing lifecycle controller.
- Produces: `AudioController.setChapterIntent()`, `AudioController.setDriveEnergy()`, an `AudioGraphFactory(AudioInitialState)`, and independent sync watches. The current master remains audible while later tasks add the two adaptive buses.
The final public audio command boundary is:
```ts
export interface AudioGraph {
resume(): Promise<void>;
suspend(): Promise<void>;
close(): Promise<void>;
connectOutput(): void;
disconnectOutput(): void;
setOutputGain(value: number): void;
setChapterIntent(intent: ChapterIntent): void;
setDriveEnergy(sample: DriveEnergySample): void;
cancelPending(): void;
onContextStateChange(
listener: (state: AudioContextLifecycleState) => void,
): () => void;
}
export type AudioGraphFactory = (initialState: AudioInitialState) => AudioGraph;
```
- [ ] **Step 1: Replace the combined lifecycle test with independent-command tests**
In `tests/unit/audio-lifecycle.test.ts`, replace the combined-state case with:
```ts
it('stores and normalizes chapter and drive independently before activation', async () => {
const harness = createHarness();
const controller = createAudioController({ createGraph: harness.createGraph });
controller.setChapterIntent({
sectionId: 'contact',
mood: 'resolve',
atStart: false,
entryPhrase: 99,
generation: -3,
});
controller.setDriveEnergy({ energy: 2, source: 'navigation' });
expect(harness.constructed).toBe(0);
await controller.startFromGesture();
expect(harness.initialStates).toEqual([{
chapter: {
sectionId: 'contact',
mood: 'resolve',
atStart: false,
entryPhrase: 7,
generation: 0,
},
drive: { energy: 1, source: 'navigation' },
}]);
controller.setChapterIntent({
sectionId: 'experience',
mood: 'shadow',
atStart: false,
entryPhrase: 2,
generation: 4,
});
controller.setDriveEnergy({ energy: 0.73, source: 'physical' });
expect(harness.graph.setChapterIntent).toHaveBeenLastCalledWith({
sectionId: 'experience',
mood: 'shadow',
atStart: false,
entryPhrase: 2,
generation: 4,
});
expect(harness.graph.setDriveEnergy).toHaveBeenLastCalledWith({
energy: 0.73,
source: 'physical',
});
});
```
Change the harness to use `setChapterIntent: vi.fn()` and `setDriveEnergy: vi.fn()`, and capture `AudioInitialState[]` from the factory.
- [ ] **Step 2: Run the lifecycle and transport tests and verify they fail**
Run:
```bash
npm run test:unit -- tests/unit/audio-lifecycle.test.ts tests/unit/soundtrack-transport.test.ts
```
Expected: FAIL because controller and graph still expose only `setJourneyState()`.
- [ ] **Step 3: Finalize the independent audio types**
Remove `AudioJourneyState` and the compatibility `MusicChapterBias` alias. Set the source and controller contracts to:
```ts
export interface SoundtrackSources {
readonly intro: readonly AudioSourceVariant[];
readonly main: readonly AudioSourceVariant[];
readonly afterglow: readonly AudioSourceVariant[];
readonly driveLift: readonly AudioSourceVariant[];
}
export interface AudioControllerOptions {
readonly createGraph: AudioGraphFactory;
readonly initialVolume?: number;
readonly initiallyVisible?: boolean;
readonly initialState?: AudioInitialState;
}
export interface AudioController {
startFromGesture(): Promise<void>;
resumeFromGesture(): Promise<void>;
setMuted(muted: boolean): void;
setVolume(volume: number): void;
setChapterIntent(intent: ChapterIntent): void;
setDriveEnergy(sample: DriveEnergySample): void;
setVisible(visible: boolean): Promise<void>;
noteInterruption(): Promise<void>;
pageHide(persisted: boolean): Promise<void>;
pageShow(persisted: boolean): Promise<void>;
dispose(): void;
whenSettled(): Promise<void>;
snapshot(): AudioUiState;
subscribe(listener: (state: AudioUiState) => void): () => void;
}
```
Add `DEFAULT_AUDIO_INITIAL_STATE` in `journey-music-state.ts`:
```ts
export const DEFAULT_AUDIO_INITIAL_STATE = Object.freeze({
chapter: DEFAULT_CHAPTER_INTENT,
drive: DEFAULT_DRIVE_ENERGY,
} as const satisfies AudioInitialState);
```
- [ ] **Step 4: Cache and forward both commands without changing lifecycle ownership**
In `create-audio-controller.ts`, replace `journeyState` with:
```ts
let chapterIntent = normalizeChapterIntent(
options.initialState?.chapter ?? DEFAULT_AUDIO_INITIAL_STATE.chapter,
);
let driveEnergy = normalizeDriveEnergy(
options.initialState?.drive ?? DEFAULT_AUDIO_INITIAL_STATE.drive,
);
```
Keep graph creation synchronous in the gesture stack:
```ts
graph = options.createGraph({ chapter: chapterIntent, drive: driveEnergy });
removeStateListener = graph.onContextStateChange((state) => {
if (state === 'interrupted') void noteInterruption();
});
graph.setOutputGain(0);
graph.setChapterIntent(chapterIntent);
graph.setDriveEnergy(driveEnergy);
```
When attaching an intact graph, reapply both latest values:
```ts
graph.connectOutput();
graph.setOutputGain(muted ? 0 : volume);
graph.setChapterIntent(chapterIntent);
graph.setDriveEnergy(driveEnergy);
```
Expose independent setters:
```ts
setChapterIntent(nextIntent) {
if (actual === 'disposed') return;
chapterIntent = normalizeChapterIntent(nextIntent);
graph?.setChapterIntent(chapterIntent);
},
setDriveEnergy(nextSample) {
if (actual === 'disposed') return;
driveEnergy = normalizeDriveEnergy(nextSample);
graph?.setDriveEnergy(driveEnergy);
},
```
Use these normalization functions:
```ts
function normalizeChapterIntent(intent: ChapterIntent): ChapterIntent {
const entryPhrase = Number.isFinite(intent.entryPhrase)
? Math.trunc(intent.entryPhrase)
: 0;
const generation = Number.isFinite(intent.generation)
? Math.trunc(intent.generation)
: 0;
return Object.freeze({
sectionId: intent.sectionId,
mood: intent.mood,
atStart: intent.atStart === true,
entryPhrase: Math.min(7, Math.max(0, entryPhrase)),
generation: Math.max(0, generation),
});
}
function normalizeDriveEnergy(sample: DriveEnergySample): DriveEnergySample {
return Object.freeze({ energy: clamp(sample.energy), source: sample.source });
}
```
Do not alter gesture, visibility, interruption, mute, volume, BFCache, failure, or teardown branches.
- [ ] **Step 5: Adapt current transport and engine to the split contract**
Change `resolveInitialPlayback` to accept `ChapterIntent`. In `soundtrack-engine.ts`, accept `AudioInitialState`, replace `latestState` with `latestChapter`, read `initialState.chapter.atStart`, and apply mood from `chapter.mood`. Expose:
```ts
setChapterIntent(intent) {
if (closed) return;
latestChapter = intent;
applyMix(intent);
if (!intent.atStart) requestIntroExit();
else cancelUnscheduledIntroExit();
},
setDriveEnergy(_sample) {
if (closed) return;
},
```
Define the compatibility cancellation helper beside `requestIntroExit()`:
```ts
const cancelUnscheduledIntroExit = (): void => {
if (exitTimer === undefined) return;
window.clearTimeout(exitTimer);
exitTimer = undefined;
};
```
The no-op drive setter is temporary but intentional: it proves that continuous drive frames cannot retarget chapter mix while Tasks 7-9 add the dedicated path. Retain all existing intro/main behavior in this task.
- [ ] **Step 6: Make the Vue engine binding watch the two refs independently**
Replace `AudioEngineSources` with:
```ts
export interface AudioEngineSources {
readonly chapter: Readonly<Ref<ChapterIntent>>;
readonly drive: Readonly<Ref<DriveEnergySample>>;
readonly soundtrack: Readonly<Ref<SoundtrackSources | null>>;
readonly volume: Readonly<Ref<number>>;
setVolume(value: number): void;
}
```
Construct and watch with:
```ts
controller = createAudioController({
createGraph: (initialState) => {
const soundtrack = sources.soundtrack.value;
if (soundtrack === null) throw new Error('Soundtrack assets are unavailable.');
return createBrowserSoundtrackEngine(soundtrack, initialState);
},
initialVolume: sources.volume.value,
initiallyVisible: document.visibilityState !== 'hidden',
initialState: {
chapter: sources.chapter.value,
drive: sources.drive.value,
},
});
```
```ts
watch(sources.chapter, (intent) => controller?.setChapterIntent(intent), { flush: 'sync' });
watch(sources.drive, (sample) => controller?.setDriveEnergy(sample), { flush: 'sync' });
watch(sources.volume, (value) => controller?.setVolume(value));
```
- [ ] **Step 7: Remove the eager destination shortcut and wire stable intent**
In `AppShell.vue`, delete `pendingAudioDestination`, `audioNavigationEpoch`, their watcher, and `audioSectionId`. Create:
```ts
const audioIntent = useAudioIntent({
canonicalIds: canonicalSectionIds,
journey: journeyBinding,
navigation: navigation.state,
});
```
Resolve every soundtrack region:
```ts
const variants = (
sources: { readonly opusAssetId: string; readonly mp3AssetId: string },
): readonly AudioSourceVariant[] => [
{ url: assetUrl(props.manifest, sources.opusAssetId), mediaType: 'audio/ogg' },
{ url: assetUrl(props.manifest, sources.mp3AssetId), mediaType: 'audio/mpeg' },
];
const soundtrackSources = computed<SoundtrackSources | null>(() => {
const soundtrack = props.content.soundtrack;
if (soundtrack === undefined) return null;
return {
intro: variants(soundtrack.intro),
main: variants(soundtrack.main),
afterglow: variants(soundtrack.afterglow),
driveLift: variants(soundtrack.driveLift),
};
});
const audio = useAudioEngine({
chapter: audioIntent.chapter,
drive: audioIntent.drive,
soundtrack: soundtrackSources,
volume: motion.volume,
setVolume: motion.setVolume,
});
```
Import `useAudioIntent` and `AudioSourceVariant`; remove now-unused `watch` or `shallowRef` imports only when no other caller uses them.
- [ ] **Step 8: Run the focused lifecycle, intent, and type checks**
Run:
```bash
npm run test:unit -- tests/unit/audio-lifecycle.test.ts tests/unit/soundtrack-transport.test.ts tests/unit/audio-intent-coordinator.test.ts tests/unit/use-audio-intent.test.ts tests/unit/navigation-controller.test.ts tests/unit/section-controller.test.ts
npm run typecheck
```
Expected: all checks pass. The master still plays, but continuous drive energy no longer touches its chapter gain/filter path.
- [ ] **Step 9: Commit the split command boundary**
Run:
```bash
git add src/audio/types.ts src/audio/create-audio-controller.ts src/audio/journey-music-state.ts src/audio/soundtrack-transport.ts src/audio/soundtrack-engine.ts src/composables/useAudioEngine.ts src/components/AppShell.vue tests/unit/audio-lifecycle.test.ts tests/unit/soundtrack-transport.test.ts
git commit -m "refactor: split soundtrack chapter and drive commands"
```
Expected: a lifecycle-preserving refactor with stable intent wired end to end.
### Task 7: Restore the restrained drive texture as an isolated module
**Files:**
- Create: `tests/helpers/fake-audio-context.ts`
- Create: `src/audio/drive-texture.ts`
- Create: `tests/unit/drive-texture.test.ts`
**Interfaces:**
- Consumes: the soundtrack engine's existing `AudioContext`, one destination bus, and normalized `DriveEnergySample` commands.
- Produces: one deterministic, preallocated `DriveTexture` whose cruise bed is always quiet, whose targets move over 220/900 ms, and whose owned nodes dispose exactly once.
```ts
export interface DriveTexture {
setEnergy(sample: DriveEnergySample): void;
dispose(): void;
}
export type DriveTextureFactory = (
context: AudioContext,
destination: AudioNode,
) => DriveTexture;
```
- [ ] **Step 1: Build the minimal call-recording Web Audio test helper**
Create a fake that records scheduling rather than emulating sound. Its public records must be:
```ts
export type AudioParamEvent =
| { readonly kind: 'set'; readonly value: number; readonly at: number }
| { readonly kind: 'linear'; readonly value: number; readonly at: number }
| { readonly kind: 'exponential'; readonly value: number; readonly at: number }
| { readonly kind: 'target'; readonly value: number; readonly at: number; readonly constant: number }
| { readonly kind: 'cancel'; readonly at: number }
| { readonly kind: 'hold'; readonly at: number };
export interface FakeAudioParamRecord {
readonly param: AudioParam;
readonly events: AudioParamEvent[];
}
export interface FakeSourceRecord {
readonly kind: 'buffer' | 'oscillator';
readonly node: AudioScheduledSourceNode;
readonly starts: Array<{ at: number; offset: number; duration: number | null }>;
readonly stops: number[];
disconnectCount: number;
emitEnded(): void;
}
export interface FakeAudioHarness {
readonly context: AudioContext;
readonly params: FakeAudioParamRecord[];
readonly sources: FakeSourceRecord[];
readonly gainNodes: GainNode[];
readonly buffers: AudioBuffer[];
readonly destination: AudioNode;
setCurrentTime(seconds: number): void;
decodedBuffer(durationSeconds: number, channels?: number): AudioBuffer;
emitContextState(state: AudioContextState | 'interrupted'): void;
}
```
Use a small parameter factory that updates `.value` and records every supported event:
```ts
const createParam = (
records: FakeAudioParamRecord[],
initial = 0,
): AudioParam => {
const events: AudioParamEvent[] = [];
const parameter = {
value: initial,
defaultValue: initial,
minValue: Number.NEGATIVE_INFINITY,
maxValue: Number.POSITIVE_INFINITY,
automationRate: 'a-rate' as const,
setValueAtTime(value: number, at: number) {
this.value = value;
events.push({ kind: 'set', value, at });
return this as unknown as AudioParam;
},
linearRampToValueAtTime(value: number, at: number) {
this.value = value;
events.push({ kind: 'linear', value, at });
return this as unknown as AudioParam;
},
exponentialRampToValueAtTime(value: number, at: number) {
this.value = value;
events.push({ kind: 'exponential', value, at });
return this as unknown as AudioParam;
},
setTargetAtTime(value: number, at: number, constant: number) {
this.value = value;
events.push({ kind: 'target', value, at, constant });
return this as unknown as AudioParam;
},
cancelScheduledValues(at: number) {
events.push({ kind: 'cancel', at });
return this as unknown as AudioParam;
},
cancelAndHoldAtTime(at: number) {
events.push({ kind: 'hold', at });
return this as unknown as AudioParam;
},
setValueCurveAtTime() {
throw new Error('Unexpected value-curve scheduling in focused audio tests.');
},
} as unknown as AudioParam;
records.push({ param: parameter, events });
return parameter;
};
```
Create lightweight gain, filter, panner, buffer-source, oscillator, buffer, and destination objects with `connect()` and counted `disconnect()`. Push every created gain into `gainNodes` and every created buffer into `buffers` in creation order. `start()` records default `at = 0`, `offset = 0`, and `duration = null`; `stop()` records once per call; `emitEnded()` invokes registered one-shot `ended` listeners. The fake context must expose only the methods used by `drive-texture.ts`, `adaptive-mixer.ts`, and `soundtrack-engine.ts`: `createGain`, `createBiquadFilter`, `createStereoPanner`, `createBuffer`, `createBufferSource`, `createOscillator`, `decodeAudioData`, `resume`, `suspend`, `close`, `addEventListener`, and `removeEventListener`. Throw on any unexpected method so tests reveal graph expansion.
- [ ] **Step 2: Write failing drive-texture tests**
Create `tests/unit/drive-texture.test.ts`:
```ts
it('preallocates one texture and uses exact attack and release ramps', () => {
const harness = createFakeAudioHarness();
const texture = createDriveTexture(harness.context, harness.destination);
const sourceCount = harness.sources.length;
const eventCount = () => harness.params.flatMap((record) => record.events).length;
harness.setCurrentTime(2);
const beforeAttack = eventCount();
texture.setEnergy({ energy: 1, source: 'physical' });
expect(harness.sources).toHaveLength(sourceCount);
expect(harness.params.flatMap((record) => record.events).slice(beforeAttack))
.toContainEqual(expect.objectContaining({ kind: 'linear', at: 2.22 }));
harness.setCurrentTime(3);
const beforeRelease = eventCount();
texture.setEnergy({ energy: 0, source: 'idle' });
expect(harness.sources).toHaveLength(sourceCount);
expect(harness.params.flatMap((record) => record.events).slice(beforeRelease))
.toContainEqual(expect.objectContaining({ kind: 'linear', at: 3.9 }));
});
it('stops and disconnects every scheduled source exactly once', () => {
const harness = createFakeAudioHarness();
const texture = createDriveTexture(harness.context, harness.destination);
texture.dispose();
texture.dispose();
for (const source of harness.sources) {
expect(source.stops).toHaveLength(1);
expect(source.disconnectCount).toBe(1);
}
});
```
Add one test that constructs two harnesses and asserts their two-channel noise buffers contain identical samples for the fixed seed while left and right channels differ from each other.
- [ ] **Step 3: Run the texture test and verify it fails**
Run:
```bash
npm run test:unit -- tests/unit/drive-texture.test.ts
```
Expected: FAIL because neither the fake context nor `createDriveTexture` exists.
- [ ] **Step 4: Implement deterministic stereo noise and reusable source helpers**
Use a fixed local generator; do not call `Math.random()`:
```ts
const NOISE_SECONDS = 2;
const NOISE_SEED = 0x4e494748;
function createNoiseBuffer(
context: BaseAudioContext,
seed: number,
): AudioBuffer {
const length = Math.max(1, Math.round(context.sampleRate * NOISE_SECONDS));
const buffer = context.createBuffer(2, length, context.sampleRate);
let state = seed >>> 0;
for (let channel = 0; channel < 2; channel += 1) {
const samples = buffer.getChannelData(channel);
let previous = 0;
for (let index = 0; index < samples.length; index += 1) {
state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0;
const white = state / 0xffff_ffff * 2 - 1;
previous = previous * 0.9 + white * 0.1;
samples[index] = previous;
}
}
return buffer;
}
function createOscillator(
context: AudioContext,
type: OscillatorType,
frequency: number,
level: number,
destination: AudioNode,
): { readonly source: OscillatorNode; readonly gain: GainNode } {
const source = context.createOscillator();
const gain = context.createGain();
source.type = type;
source.frequency.setValueAtTime(frequency, context.currentTime);
gain.gain.setValueAtTime(level, context.currentTime);
source.connect(gain);
gain.connect(destination);
return { source, gain };
}
```
- [ ] **Step 5: Build the restrained fixed graph and exact response envelope**
Use these conservative targets in `createDriveTexture`:
```ts
const ATTACK_SECONDS = 0.22;
const RELEASE_SECONDS = 0.9;
const CRUISE_ENGINE_GAIN = 0.055;
const MAX_ENGINE_GAIN = 0.12;
const CRUISE_ROAD_GAIN = 0.006;
const MAX_ROAD_GAIN = 0.024;
```
Construct once:
```ts
const engineFilter = context.createBiquadFilter();
engineFilter.type = 'lowpass';
engineFilter.frequency.value = 240;
engineFilter.Q.value = 1.1;
const engineGain = context.createGain();
engineGain.gain.value = CRUISE_ENGINE_GAIN;
engineFilter.connect(engineGain);
engineGain.connect(destination);
const fundamental = createOscillator(context, 'sawtooth', 42, 0.1, engineFilter);
const harmonic = createOscillator(context, 'sine', 84, 0.035, engineFilter);
const roadSource = context.createBufferSource();
roadSource.buffer = createNoiseBuffer(context, NOISE_SEED);
roadSource.loop = true;
const roadHighpass = context.createBiquadFilter();
roadHighpass.type = 'highpass';
roadHighpass.frequency.value = 180;
const roadLowpass = context.createBiquadFilter();
roadLowpass.type = 'lowpass';
roadLowpass.frequency.value = 1_600;
const roadGain = context.createGain();
roadGain.gain.value = CRUISE_ROAD_GAIN;
roadSource.connect(roadHighpass);
roadHighpass.connect(roadLowpass);
roadLowpass.connect(roadGain);
roadGain.connect(destination);
```
The stereo buffer supplies decorrelated channels; do not collapse it before the filters. Add a short triangle ignition through the engine filter, peaking at `0.008` after 120 ms and returning to `0.0001` by 1.15 seconds. Start the two engine oscillators, the looping road source, and ignition exactly once.
Drive automation uses a single helper:
```ts
let currentEnergy = 0;
const automate = (
parameter: AudioParam,
value: number,
duration: number,
): void => {
const now = context.currentTime;
holdParameter(parameter, now);
parameter.linearRampToValueAtTime(value, now + duration);
};
const setEnergy = (sample: DriveEnergySample): void => {
if (disposed) return;
const next = Math.min(1, Math.max(0, sample.energy));
const duration = next > currentEnergy ? ATTACK_SECONDS : RELEASE_SECONDS;
automate(fundamental.source.frequency, 42 + next * 20, duration);
automate(harmonic.source.frequency, 84 + next * 40, duration);
automate(engineFilter.frequency, 240 + next * 460, duration);
automate(roadLowpass.frequency, 1_600 + next * 1_800, duration);
automate(engineGain.gain, CRUISE_ENGINE_GAIN + next * (MAX_ENGINE_GAIN - CRUISE_ENGINE_GAIN), duration);
automate(roadGain.gain, CRUISE_ROAD_GAIN + next * (MAX_ROAD_GAIN - CRUISE_ROAD_GAIN), duration);
currentEnergy = next;
};
```
Reuse the `holdParameter` fallback used by the soundtrack engine. Keep every node in an ownership array. `dispose()` must guard with `disposed`, stop each scheduled source inside `try/catch`, disconnect each source and processing node once, and never close the shared context.
- [ ] **Step 6: Run the focused texture test and typecheck**
Run:
```bash
npm run test:unit -- tests/unit/drive-texture.test.ts
npm run typecheck
```
Expected: both pass; repeated energy commands add no nodes; attack/release timing and exact-once cleanup are recorded.
- [ ] **Step 7: Commit the drive texture**
Run:
```bash
git add tests/helpers/fake-audio-context.ts src/audio/drive-texture.ts tests/unit/drive-texture.test.ts
git commit -m "feat: restore restrained drive texture"
```
Expected: one independent DSP module with no connection to the live graph yet.
### Task 8: Add exact transport math and the adaptive mixer
**Files:**
- Modify: `src/audio/soundtrack-transport.ts`
- Create: `src/audio/adaptive-mixer.ts`
- Modify: `tests/helpers/fake-audio-context.ts`
- Modify: `tests/unit/soundtrack-transport.test.ts`
- Create: `tests/unit/adaptive-mixer.test.ts`
**Interfaces:**
- Consumes: the main transport anchor, `ChapterIntent`, `DriveEnergySample`, one shared `AudioContext`, and conservative authored-layer gain assumptions.
- Produces: exact sample-derived schedule helpers and `AdaptiveMixer` input buses with 1.4-second mood ramps and cancellable beat-aligned drive-lift automation.
```ts
export interface AdaptiveMixer {
readonly bedInput: GainNode;
readonly driveLiftInput: GainNode;
readonly driveTextureInput: GainNode;
setChapterIntent(intent: ChapterIntent): void;
setDriveEnergy(sample: DriveEnergySample): void;
setTransportAnchor(anchorTime: number | null): void;
setDriveLiftAvailableAt(startTime: number | null): void;
dispose(): void;
}
export type AdaptiveMixerFactory = (
context: AudioContext,
destination: AudioNode,
) => AdaptiveMixer;
```
- [ ] **Step 1: Write failing exact-timeline tests**
Extend `tests/unit/soundtrack-transport.test.ts`:
```ts
it('pins the short adaptive regions to whole samples', () => {
expect(MUSIC_BEAT_SAMPLES).toBe(33_488);
expect(MUSIC_BAR_SAMPLES).toBe(133_953);
expect(DRIVE_LIFT_LOOP_SAMPLES).toBe(535_814);
expect(AFTERGLOW_LOOP_START_SAMPLE).toBe(133_953);
expect(AFTERGLOW_LOOP_END_SAMPLE).toBe(1_205_581);
expect(MAIN_DURATION_SAMPLES).toBe(8_573_023);
});
it('derives one immutable main anchor and the final-bar afterglow handoff', () => {
expect(resolveMainSchedule({
startTime: 10,
offsetSeconds: mainPhraseOffsetSeconds(6),
bufferDuration: MAIN_DURATION_SECONDS,
})).toEqual({
sourceStartTime: 10,
sourceOffsetSeconds: mainPhraseOffsetSeconds(6),
musicalAnchorTime: 10 - mainPhraseOffsetSeconds(6),
sourceEndTime: 10 + MAIN_DURATION_SECONDS - mainPhraseOffsetSeconds(6),
afterglowStartTime: 10 + MAIN_DURATION_SECONDS - mainPhraseOffsetSeconds(6) - AFTERGLOW_TRANSITION_SECONDS,
});
});
it('aligns a late loop source and sustained entry to the master beat', () => {
expect(phaseAlignedLoopOffset(13, 10, DRIVE_LIFT_LOOP_SECONDS))
.toBeCloseTo(3, 8);
expect(nextBeatTime(11.45, 10)).toBeCloseTo(
10 + 3 * MUSIC_BEAT_SECONDS,
8,
);
});
```
- [ ] **Step 2: Write failing mixer tests**
Expose `gainNodes: readonly GainNode[]` from the fake harness in creation order. Then create `tests/unit/adaptive-mixer.test.ts`:
```ts
it('ramps only the four approved mood controls over 1.4 seconds', () => {
const harness = createFakeAudioHarness();
const mixer = createAdaptiveMixer(harness.context, harness.destination);
harness.setCurrentTime(2);
mixer.setChapterIntent({
sectionId: 'toolkit',
mood: 'resolve',
atStart: false,
entryPhrase: 6,
generation: 1,
});
const events = harness.params.flatMap((record) => record.events);
expect(events.filter((event) => event.kind === 'linear' && event.at === 3.4))
.toHaveLength(4);
});
it('arms lift after sustained physical input but never for navigation', () => {
const harness = createFakeAudioHarness();
const mixer = createAdaptiveMixer(harness.context, harness.destination);
const liftGate = harness.gainNodes[3]!.gain;
mixer.setTransportAnchor(0);
mixer.setDriveLiftAvailableAt(0);
harness.setCurrentTime(1);
mixer.setDriveEnergy({ energy: 1, source: 'navigation' });
expect(recordFor(harness, liftGate).events)
.not.toContainEqual(expect.objectContaining({ kind: 'linear', value: 1 }));
mixer.setDriveEnergy({ energy: 0.7, source: 'physical' });
expect(recordFor(harness, liftGate).events).toContainEqual({
kind: 'linear',
value: 1,
at: 4 * MUSIC_BEAT_SECONDS,
});
});
it('cancels an unsustained entry and schedules hysteretic exit', () => {
const harness = createFakeAudioHarness();
const mixer = createAdaptiveMixer(harness.context, harness.destination);
const liftGate = harness.gainNodes[3]!.gain;
mixer.setTransportAnchor(0);
mixer.setDriveLiftAvailableAt(0);
harness.setCurrentTime(1);
mixer.setDriveEnergy({ energy: 0.7, source: 'physical' });
harness.setCurrentTime(1.2);
mixer.setDriveEnergy({ energy: 0.5, source: 'physical' });
expect(recordFor(harness, liftGate).events)
.toContainEqual({ kind: 'hold', at: 1.2 });
harness.setCurrentTime(2);
mixer.setDriveEnergy({ energy: 0.7, source: 'physical' });
harness.setCurrentTime(5);
mixer.setDriveEnergy({ energy: 0.2, source: 'physical' });
expect(recordFor(harness, liftGate).events)
.toContainEqual(expect.objectContaining({ kind: 'linear', value: 0 }));
});
it('replays cached strong drive only after a late loop source is ready', () => {
const harness = createFakeAudioHarness();
const mixer = createAdaptiveMixer(harness.context, harness.destination);
const liftGate = harness.gainNodes[3]!.gain;
mixer.setDriveEnergy({ energy: 0.8, source: 'physical' });
mixer.setTransportAnchor(0);
expect(recordFor(harness, liftGate).events)
.not.toContainEqual(expect.objectContaining({ kind: 'linear', value: 1 }));
harness.setCurrentTime(5);
mixer.setDriveLiftAvailableAt(5.08);
const entryEnd = recordFor(harness, liftGate).events.findLast(
(event) => event.kind === 'linear' && event.value === 1,
);
expect(entryEnd).toBeDefined();
expect(entryEnd!.at).toBeGreaterThan(5.08 + 0.45);
expect(entryEnd!.at / MUSIC_BEAT_SECONDS).toBeCloseTo(
Math.round(entryEnd!.at / MUSIC_BEAT_SECONDS),
8,
);
});
```
Implement `recordFor()` in the test by finding the fake parameter record whose `param` is the supplied `AudioParam`. Add an idempotent disposal assertion.
- [ ] **Step 3: Run the focused transport/mixer tests and verify they fail**
Run:
```bash
npm run test:unit -- tests/unit/soundtrack-transport.test.ts tests/unit/adaptive-mixer.test.ts
```
Expected: FAIL because adaptive constants, schedule helpers, mixer, and fake gain-node access do not exist.
- [ ] **Step 4: Pin all timing to exact samples**
Add these constants to `soundtrack-transport.ts`:
```ts
export const MUSIC_BEAT_SECONDS = 60 / MUSIC_BPM;
export const MUSIC_BEAT_SAMPLES = Math.round(
SOUNDTRACK_SAMPLE_RATE * MUSIC_BEAT_SECONDS,
);
export const MUSIC_BAR_SAMPLES = 133_953;
export const MAIN_DURATION_SAMPLES = 8_573_023;
export const MAIN_DURATION_SECONDS = MAIN_DURATION_SAMPLES / SOUNDTRACK_SAMPLE_RATE;
export const AFTERGLOW_TRANSITION_SAMPLES = MUSIC_BAR_SAMPLES;
export const AFTERGLOW_TRANSITION_SECONDS = AFTERGLOW_TRANSITION_SAMPLES / SOUNDTRACK_SAMPLE_RATE;
export const AFTERGLOW_LOOP_START_SAMPLE = AFTERGLOW_TRANSITION_SAMPLES;
export const AFTERGLOW_LOOP_SAMPLES = 1_071_628;
export const AFTERGLOW_LOOP_END_SAMPLE = 1_205_581;
export const AFTERGLOW_LOOP_START_SECONDS = AFTERGLOW_LOOP_START_SAMPLE / SOUNDTRACK_SAMPLE_RATE;
export const AFTERGLOW_LOOP_END_SECONDS = AFTERGLOW_LOOP_END_SAMPLE / SOUNDTRACK_SAMPLE_RATE;
export const DRIVE_LIFT_LOOP_SAMPLES = 535_814;
export const DRIVE_LIFT_LOOP_SECONDS = DRIVE_LIFT_LOOP_SAMPLES / SOUNDTRACK_SAMPLE_RATE;
```
Keep `MUSIC_BAR_SECONDS = 4 * 60 / MUSIC_BPM` for musical scheduling. Add sample-tolerant beat alignment and loop phase:
```ts
export function nextBeatTime(earliest: number, anchorTime: number): number {
const elapsedBeats = (Math.max(anchorTime, earliest) - anchorTime) /
MUSIC_BEAT_SECONDS;
const oneSampleInBeats = 1 / SOUNDTRACK_SAMPLE_RATE / MUSIC_BEAT_SECONDS;
return anchorTime + Math.ceil(elapsedBeats - oneSampleInBeats) * MUSIC_BEAT_SECONDS;
}
export function phaseAlignedLoopOffset(
when: number,
anchorTime: number,
loopDuration: number,
): number {
if (!Number.isFinite(loopDuration) || loopDuration <= 0) return 0;
const elapsed = when - anchorTime;
return ((elapsed % loopDuration) + loopDuration) % loopDuration;
}
export interface MainScheduleInput {
readonly startTime: number;
readonly offsetSeconds: number;
readonly bufferDuration: number;
}
export interface MainSchedule {
readonly sourceStartTime: number;
readonly sourceOffsetSeconds: number;
readonly musicalAnchorTime: number;
readonly sourceEndTime: number;
readonly afterglowStartTime: number;
}
export function resolveMainSchedule(input: MainScheduleInput): MainSchedule {
const sourceOffsetSeconds = Math.min(
Math.max(0, input.offsetSeconds),
Math.max(0, input.bufferDuration - 0.05),
);
const sourceEndTime = input.startTime + input.bufferDuration - sourceOffsetSeconds;
return {
sourceStartTime: input.startTime,
sourceOffsetSeconds,
musicalAnchorTime: input.startTime - sourceOffsetSeconds,
sourceEndTime,
afterglowStartTime: sourceEndTime - AFTERGLOW_TRANSITION_SECONDS,
};
}
```
- [ ] **Step 5: Construct the fixed-headroom mixer**
Use these exact profiles in `adaptive-mixer.ts`:
```ts
const MOOD_RAMP_SECONDS = 1.4;
interface MixProfile {
readonly bedGain: number;
readonly highShelfDb: number;
readonly textureSend: number;
readonly liftCap: number;
}
const MIX_PROFILES = Object.freeze({
open: { bedGain: 0.82, highShelfDb: 0, textureSend: 0.82, liftCap: 0.13 },
shadow: { bedGain: 0.81, highShelfDb: -0.75, textureSend: 0.9, liftCap: 0.1 },
lift: { bedGain: 0.82, highShelfDb: 0.65, textureSend: 0.9, liftCap: 0.17 },
resolve: { bedGain: 0.81, highShelfDb: -0.15, textureSend: 0.74, liftCap: 0.11 },
} as const satisfies Readonly<Record<MusicMood, MixProfile>>);
```
Create gains in this exact order so the focused fake can identify the gate: `bedInput`, `bedTrim`, `driveLiftInput`, `driveLiftGate`, `driveLiftCap`, `driveTextureInput`, `textureSend`, `sum`. Route:
```text
bedInput -> bedTrim -> highShelf -> sum -> destination
driveLiftInput -> driveLiftGate -> driveLiftCap -> sum
driveTextureInput -> textureSend -> sum
```
Set high shelf type to `highshelf`, frequency to `5200`, Q to `0.55`; initialize from `open`; initialize lift gate to zero. Do not create a compressor or stereo-width processor.
Apply mood changes through exactly four 1.4-second ramps:
```ts
const setChapterIntent = (intent: ChapterIntent): void => {
if (disposed) return;
const profile = MIX_PROFILES[intent.mood];
ramp(bedTrim.gain, profile.bedGain, MOOD_RAMP_SECONDS);
ramp(highShelf.gain, profile.highShelfDb, MOOD_RAMP_SECONDS);
ramp(textureSend.gain, profile.textureSend, MOOD_RAMP_SECONDS);
ramp(driveLiftCap.gain, profile.liftCap, MOOD_RAMP_SECONDS);
};
```
- [ ] **Step 6: Implement sustained, cancellable, beat-aligned drive lift**
Use:
```ts
const LIFT_ENTRY_ENERGY = 0.62;
const LIFT_EXIT_ENERGY = 0.32;
const LIFT_ENTRY_DWELL_SECONDS = 0.45;
const LIFT_EXIT_DWELL_SECONDS = 0.8;
type LiftPhase = 'off' | 'entering' | 'on' | 'exiting';
let transportAnchor: number | null = null;
let driveLiftAvailableAt: number | null = null;
let latestDrive: DriveEnergySample = { energy: 0, source: 'idle' };
let liftPhase: LiftPhase = 'off';
let scheduledStart = 0;
let scheduledEnd = 0;
```
Reconcile elapsed scheduled states before each command:
```ts
const reconcileLift = (now: number): void => {
if (liftPhase === 'entering' && now >= scheduledEnd) liftPhase = 'on';
if (liftPhase === 'exiting' && now >= scheduledEnd) liftPhase = 'off';
};
```
Schedule entry from the first above-threshold sample, so remaining above threshold preserves the schedule while dropping below `0.62` before its start cancels it:
```ts
const scheduleEntry = (now: number): void => {
if (
transportAnchor === null ||
driveLiftAvailableAt === null ||
liftPhase === 'entering' ||
liftPhase === 'on'
) return;
scheduledStart = nextBeatTime(
Math.max(now, driveLiftAvailableAt) + LIFT_ENTRY_DWELL_SECONDS,
transportAnchor,
);
scheduledEnd = scheduledStart + MUSIC_BEAT_SECONDS;
holdParameter(driveLiftGate.gain, now);
driveLiftGate.gain.setValueAtTime(0, scheduledStart);
driveLiftGate.gain.linearRampToValueAtTime(1, scheduledEnd);
liftPhase = 'entering';
};
const cancelEntry = (now: number): void => {
if (liftPhase !== 'entering' || now >= scheduledStart) return;
holdParameter(driveLiftGate.gain, now);
driveLiftGate.gain.linearRampToValueAtTime(0, now + 0.05);
liftPhase = 'off';
};
```
For an audible layer, schedule release only after 800 ms below `0.32` and then fade over one beat:
```ts
const scheduleExit = (now: number): void => {
if (
transportAnchor === null ||
driveLiftAvailableAt === null ||
liftPhase === 'off' ||
liftPhase === 'exiting'
) return;
scheduledStart = nextBeatTime(
now + LIFT_EXIT_DWELL_SECONDS,
transportAnchor,
);
scheduledEnd = scheduledStart + MUSIC_BEAT_SECONDS;
holdParameter(driveLiftGate.gain, now);
driveLiftGate.gain.setValueAtTime(1, scheduledStart);
driveLiftGate.gain.linearRampToValueAtTime(0, scheduledEnd);
liftPhase = 'exiting';
};
const cancelExit = (now: number): void => {
if (liftPhase !== 'exiting' || now >= scheduledStart) return;
holdParameter(driveLiftGate.gain, now);
driveLiftGate.gain.linearRampToValueAtTime(
1,
now + MUSIC_BEAT_SECONDS,
);
liftPhase = 'on';
};
```
Drive command routing always caches the latest sample, even while intro is running or the optional loop is still decoding:
```ts
const applyLatestDrive = (): void => {
if (transportAnchor === null || driveLiftAvailableAt === null) return;
const now = context.currentTime;
reconcileLift(now);
const physical = latestDrive.source === 'physical';
if (physical && latestDrive.energy > LIFT_ENTRY_ENERGY) {
if (liftPhase === 'exiting') {
holdParameter(driveLiftGate.gain, now);
driveLiftGate.gain.linearRampToValueAtTime(1, now + MUSIC_BEAT_SECONDS);
liftPhase = 'on';
} else {
scheduleEntry(now);
}
return;
}
if (physical && latestDrive.energy >= LIFT_EXIT_ENERGY) {
if (liftPhase === 'entering' && now < scheduledStart) cancelEntry(now);
if (liftPhase === 'exiting') cancelExit(now);
return;
}
if (liftPhase === 'entering' && now < scheduledStart) cancelEntry(now);
if (!physical || latestDrive.energy < LIFT_EXIT_ENERGY) scheduleExit(now);
};
const setDriveEnergy = (sample: DriveEnergySample): void => {
if (disposed) return;
latestDrive = sample;
applyLatestDrive();
};
```
Use one guarded mute/reset helper, and make both prerequisites replay the cached sample:
```ts
const resetLiftGate = (): void => {
const now = context.currentTime;
holdParameter(driveLiftGate.gain, now);
driveLiftGate.gain.linearRampToValueAtTime(0, now + 0.05);
liftPhase = 'off';
scheduledStart = 0;
scheduledEnd = 0;
};
const setTransportAnchor = (anchorTime: number | null): void => {
if (disposed) return;
transportAnchor = anchorTime;
if (anchorTime === null) resetLiftGate();
else applyLatestDrive();
};
const setDriveLiftAvailableAt = (startTime: number | null): void => {
if (disposed) return;
driveLiftAvailableAt = startTime;
if (startTime === null) resetLiftGate();
else applyLatestDrive();
};
```
A non-null anchor alone cannot make the layer audible; its decoded looping source must separately report its scheduled start time. `dispose()` calls `resetLiftGate()`, disconnects each owned mixer node once, and does not disconnect the supplied destination.
- [ ] **Step 7: Run the focused timing and mixer checks**
Run:
```bash
npm run test:unit -- tests/unit/soundtrack-transport.test.ts tests/unit/adaptive-mixer.test.ts tests/unit/drive-texture.test.ts
npm run typecheck
```
Expected: all checks pass; navigation cannot arm lift; thresholds cannot chatter; four mood controls ramp independently; exact sample constants stay pinned.
- [ ] **Step 8: Commit transport and mixer behavior**
Run:
```bash
git add src/audio/soundtrack-transport.ts src/audio/adaptive-mixer.ts tests/helpers/fake-audio-context.ts tests/unit/soundtrack-transport.test.ts tests/unit/adaptive-mixer.test.ts
git commit -m "feat: add adaptive soundtrack mixer"
```
Expected: one commit with pure transport math and the fixed adaptive graph, still not connected to live sources.
### Task 9: Orchestrate the authored bed, cancellable intro, overlays, and texture
**Files:**
- Modify: `src/audio/soundtrack-engine.ts`
- Modify: `tests/helpers/fake-audio-context.ts`
- Create: `tests/unit/soundtrack-engine.test.ts`
- Modify: `tests/browser/audio-interruption.spec.ts` only if its existing semantic assertions require an adapter
**Interfaces:**
- Consumes: four `SoundtrackSources`, `AudioInitialState`, `AdaptiveMixer`, `DriveTexture`, exact transport helpers, and the existing Opus-first/MP3-fallback loader.
- Produces: one `AudioGraph` with terminal intro/main failures, independent optional-layer degradation, a cancellable Start exit, one immutable main anchor, a sample-addressed afterglow loop, a phase-aligned muted drive loop, preserved fault hooks, and exact-once cleanup.
Split browser construction from the injectable core:
```ts
export interface SoundtrackEngineDependencies {
createContext(): AudioContext;
loadBuffer(
context: BaseAudioContext,
variants: readonly AudioSourceVariant[],
signal: AbortSignal,
): Promise<AudioBuffer>;
createMixer: AdaptiveMixerFactory;
createDriveTexture: DriveTextureFactory;
}
export function createSoundtrackEngine(
sources: SoundtrackSources,
initialState: AudioInitialState,
dependencies: SoundtrackEngineDependencies,
): AudioGraph;
export function createBrowserSoundtrackEngine(
sources: SoundtrackSources,
initialState: AudioInitialState,
): AudioGraph;
```
- [ ] **Step 1: Write the focused engine test harness and failing tests**
Use four unmistakable URLs and injected decoded buffers:
```ts
const sources: SoundtrackSources = {
intro: [{ url: '/intro.opus', mediaType: 'audio/ogg' }],
main: [{ url: '/main.opus', mediaType: 'audio/ogg' }],
afterglow: [{ url: '/afterglow.opus', mediaType: 'audio/ogg' }],
driveLift: [{ url: '/drive-lift.opus', mediaType: 'audio/ogg' }],
};
const initialState: AudioInitialState = {
chapter: resolveChapterIntent('start'),
drive: { energy: 0, source: 'idle' },
};
```
Have the loader return fake buffers with these exact durations:
```ts
const buffers = new Map([
['/intro.opus', harness.decodedBuffer(INTRO_RUNTIME_END_SECONDS)],
['/main.opus', harness.decodedBuffer(MAIN_DURATION_SECONDS)],
['/afterglow.opus', harness.decodedBuffer(AFTERGLOW_LOOP_END_SECONDS)],
['/drive-lift.opus', harness.decodedBuffer(DRIVE_LIFT_LOOP_SECONDS)],
]);
```
Have `createEngineHarness()` tag each returned fake buffer with the requesting variant URL and expose `sourcesFor(url)` by matching the source node's assigned buffer. This makes intro lead/loop/outro and main/optional ownership assertions deterministic without relying on source creation order.
Add these high-value cases:
```ts
it('cancels a Start departure before its audible bar boundary', async () => {
const test = createEngineHarness(initialState);
await test.graph.resume();
test.graph.setChapterIntent(resolveChapterIntent('profile', 1));
const pendingMain = test.sourcesFor('/main.opus').at(-1)!;
const pendingOutro = test.sourcesFor('/intro.opus').at(-1)!;
expect(pendingMain.starts).toHaveLength(1);
expect(pendingOutro.starts).toHaveLength(1);
test.graph.setChapterIntent(resolveChapterIntent('start', 2));
expect(pendingMain.stops).toHaveLength(1);
expect(pendingOutro.stops).toHaveLength(1);
expect(test.mixer.setTransportAnchor).not.toHaveBeenCalledWith(expect.any(Number));
});
it('reuses one pending Start departure across rapid non-Start moods', async () => {
const test = createEngineHarness(initialState);
await test.graph.resume();
test.graph.setChapterIntent(resolveChapterIntent('profile', 1));
const mainCount = test.sourcesFor('/main.opus').length;
const introCount = test.sourcesFor('/intro.opus').length;
test.graph.setChapterIntent(resolveChapterIntent('experience', 2));
test.graph.setChapterIntent(resolveChapterIntent('toolkit', 3));
expect(test.sourcesFor('/main.opus')).toHaveLength(mainCount);
expect(test.sourcesFor('/intro.opus')).toHaveLength(introCount);
expect(test.mixer.setChapterIntent).toHaveBeenLastCalledWith(
resolveChapterIntent('toolkit', 3),
);
});
it('keeps the main anchor unchanged across every mood command', async () => {
const test = createEngineHarness({
chapter: resolveChapterIntent('experience'),
drive: { energy: 0, source: 'idle' },
});
await test.graph.resume();
const firstAnchor = test.mixer.setTransportAnchor.mock.calls.find(
([anchor]) => typeof anchor === 'number',
)?.[0];
test.graph.setChapterIntent(resolveChapterIntent('contact', 1));
test.graph.setChapterIntent(resolveChapterIntent('start', 2));
expect(test.mixer.setTransportAnchor.mock.calls.filter(
([anchor]) => typeof anchor === 'number',
).map(([anchor]) => anchor)).toEqual([firstAnchor]);
});
it('starts one afterglow transition and loops only its eight-bar idle region', async () => {
const test = createEngineHarness({
chapter: resolveChapterIntent('toolkit'),
drive: { energy: 0, source: 'idle' },
});
await test.graph.resume();
await test.flushOptionalLoads();
const afterglow = test.sourceFor('/afterglow.opus');
expect(afterglow.starts).toHaveLength(1);
expect((afterglow.node as AudioBufferSourceNode).loop).toBe(true);
expect((afterglow.node as AudioBufferSourceNode).loopStart)
.toBe(AFTERGLOW_LOOP_START_SECONDS);
expect((afterglow.node as AudioBufferSourceNode).loopEnd)
.toBe(AFTERGLOW_LOOP_END_SECONDS);
});
it('contains optional failures and still resolves dominant playback', async () => {
const test = createEngineHarness(initialState, {
rejectAfterglow: true,
rejectDriveLift: true,
rejectDriveTexture: true,
});
await expect(test.graph.resume()).resolves.toBeUndefined();
await test.flushOptionalLoads();
expect(test.sourceFor('/intro.opus').starts).toHaveLength(1);
});
```
Add one direct-activation assertion for phrase-seven source offset, one phase-aligned late drive-source offset assertion, and one close-twice assertion proving each owned source/node is stopped/disconnected once. The harness must expose rejected optional loads as handled promises so the test runner reports no unhandled rejection.
- [ ] **Step 2: Run the focused engine test and verify it fails**
Run:
```bash
npm run test:unit -- tests/unit/soundtrack-engine.test.ts
```
Expected: FAIL because the injectable engine, optional layer scheduling, and cancellable transition do not exist.
- [ ] **Step 3: Build the one-context graph and source ownership helpers**
At construction, create exactly one context, output gain, mixer, and optional drive texture:
```ts
const context = dependencies.createContext();
const outputGain = context.createGain();
outputGain.gain.value = 0;
const mixer = dependencies.createMixer(context, outputGain);
let driveTexture: DriveTexture | null = null;
try {
driveTexture = dependencies.createDriveTexture(
context,
mixer.driveTextureInput,
);
} catch {
driveTexture = null;
}
```
Track every scheduled source through one idempotent ownership record. Scheduled future stops remain connected until `ended`; cancellation and close release immediately. All paths share the same guarded cleanup, so a later `ended` event cannot disconnect twice:
```ts
interface OwnedSource {
readonly node: AudioBufferSourceNode;
stopAt(when: number): void;
cancel(): void;
}
interface SourceHandle extends OwnedSource {
readonly gain: GainNode;
}
const ownedSources = new Set<OwnedSource>();
const ownSource = (
node: AudioBufferSourceNode,
releaseDependents: () => void,
): OwnedSource => {
let released = false;
let stopCalled = false;
let owned: OwnedSource;
const release = (): void => {
if (released) return;
released = true;
ownedSources.delete(owned);
node.disconnect();
releaseDependents();
};
const stopOnce = (when?: number): void => {
if (stopCalled) return;
stopCalled = true;
try {
if (when === undefined) node.stop();
else node.stop(when);
} catch {
// An already-ended source is released by the same idempotent path.
}
};
owned = {
node,
stopAt(when) { stopOnce(when); },
cancel() {
stopOnce();
release();
},
};
ownedSources.add(owned);
node.addEventListener('ended', release, { once: true });
return owned;
};
const makeSource = (
buffer: AudioBuffer,
destination: AudioNode,
): SourceHandle => {
const node = context.createBufferSource();
const gain = context.createGain();
node.buffer = buffer;
node.connect(gain);
gain.connect(destination);
const owned = ownSource(node, () => gain.disconnect());
return { ...owned, gain };
};
const makeMarker = (
when: number,
onEnded: () => void,
): OwnedSource => {
const node = context.createBufferSource();
node.buffer = context.createBuffer(1, 1, context.sampleRate);
const marker = ownSource(node, () => {});
node.addEventListener('ended', onEnded, { once: true });
node.start(when);
return marker;
};
let introSourceNodes: SourceHandle[] = [];
let driveLiftSource: SourceHandle | null = null;
let afterglowSource: SourceHandle | null = null;
let mainSource: SourceHandle | null = null;
let loadedMainBuffer: AudioBuffer | null = null;
let mainSchedule: MainSchedule | null = null;
```
Use one abort controller for graph-owned requests. Dominant intro/main promises may reject `resume()`; optional afterglow/drive-lift promises must catch and resolve to `null`:
```ts
const loadOptional = (
variants: readonly AudioSourceVariant[],
): Promise<AudioBuffer | null> => dependencies
.loadBuffer(context, variants, loadController.signal)
.catch(() => null);
```
- [ ] **Step 4: Start intro or direct main without delaying on optional assets**
Keep the existing sample-addressed intro lead and loop. Start drive-lift loading only after `context.resume()` succeeds, without awaiting it:
```ts
let driveLiftBufferPromise: Promise<AudioBuffer | null> | null = null;
let afterglowBufferPromise: Promise<AudioBuffer | null> | null = null;
let optionalScheduleAttached = false;
driveLiftBufferPromise ??= loadOptional(sources.driveLift);
mainBufferPromise ??= dependencies.loadBuffer(
context,
sources.main,
loadController.signal,
);
```
For direct activation, resolve the existing mapped phrase and create one immutable schedule:
```ts
const when = context.currentTime + SOURCE_LEAD_SECONDS;
const schedule = resolveMainSchedule({
startTime: when,
offsetSeconds: mainPhraseOffsetSeconds(latestChapter.entryPhrase),
bufferDuration: mainBuffer.duration,
});
const main = makeSource(mainBuffer, mixer.bedInput);
main.gain.gain.setValueAtTime(0, when);
main.gain.gain.linearRampToValueAtTime(1, when + START_FADE_SECONDS);
main.node.start(when, schedule.sourceOffsetSeconds);
mainCommitted = true;
mainSource = main;
mainSchedule = schedule;
mixer.setTransportAnchor(schedule.musicalAnchorTime);
beginOptionalLayers(schedule);
```
Direct activation may publish its anchor immediately because the 450 ms drive-lift dwell is longer than `SOURCE_LEAD_SECONDS`. The intro path uses the identity-guarded `mainStartMarker` in Step 5, preventing drive lift from becoming audible during intro-idle or intro-out.
- [ ] **Step 5: Replace scheduled commitment with a cancellable transition record**
Use:
```ts
interface PendingIntroExit {
readonly requestedGeneration: number;
readonly transitionTime: number;
readonly outro: SourceHandle;
readonly main: SourceHandle;
readonly commitMarker: OwnedSource;
readonly mainStartMarker: OwnedSource;
readonly schedule: MainSchedule;
}
let pendingIntroExit: PendingIntroExit | null = null;
```
Schedule outro, main, gain automation, and two unconnected one-sample markers, but do not stop active intro sources and do not set `mainCommitted` yet:
```ts
const scheduleIntroExit = (
introBuffer: AudioBuffer,
mainBuffer: AudioBuffer,
requestedGeneration: number,
): void => {
if (closed || mainCommitted || pendingIntroExit !== null || latestChapter.atStart) return;
const transitionTime = nextSafeBarTime(
Math.max(
context.currentTime,
introAnchorTime + INTRO_IN_END_SECONDS - EXIT_MINIMUM_LEAD_SECONDS,
),
introAnchorTime,
EXIT_MINIMUM_LEAD_SECONDS,
);
const outro = makeSource(introBuffer, mixer.bedInput);
outro.gain.gain.setValueAtTime(0, transitionTime);
outro.gain.gain.linearRampToValueAtTime(
1,
transitionTime + EXIT_CROSSFADE_SECONDS,
);
outro.node.start(
transitionTime,
INTRO_OUT_START_SECONDS,
INTRO_RUNTIME_END_SECONDS - INTRO_OUT_START_SECONDS,
);
const mainTime = transitionTime + INTRO_OUT_END_SECONDS - INTRO_OUT_START_SECONDS;
const schedule = resolveMainSchedule({
startTime: mainTime,
offsetSeconds: 0,
bufferDuration: mainBuffer.duration,
});
const main = makeSource(mainBuffer, mixer.bedInput);
main.gain.gain.setValueAtTime(0, mainTime);
main.gain.gain.linearRampToValueAtTime(1, mainTime + EXIT_CROSSFADE_SECONDS);
main.node.start(mainTime, 0);
```
Create the pending record before attaching its two identity-guarded marker callbacks. A later non-Start mood/generation reuses this same departure; only returning to Start or graph cancellation invalidates it:
```ts
let pending: PendingIntroExit;
const commitMarker = makeMarker(transitionTime, () => {
if (closed || pendingIntroExit !== pending) return;
mainCommitted = true;
pendingIntroExit = null;
mainSource = main;
mainSchedule = schedule;
for (const source of introSourceNodes) {
source.stopAt(transitionTime + EXIT_CROSSFADE_SECONDS);
}
loadedIntroBuffer = null;
beginOptionalLayers(schedule);
});
const mainStartMarker = makeMarker(mainTime, () => {
if (mainSchedule !== schedule || closed) return;
mixer.setTransportAnchor(schedule.musicalAnchorTime);
});
pending = {
requestedGeneration,
transitionTime,
outro,
main,
commitMarker,
mainStartMarker,
schedule,
};
pendingIntroExit = pending;
};
```
Both callbacks execute after `pending` is assigned. `requestIntroExit(requestedGeneration)` returns immediately when `pendingIntroExit !== null`, so a rapid sequence such as Profile -> Experience -> Toolkit updates only the mix intent and never duplicates or stales the already scheduled departure. The main-start marker sets the mixer anchor only after the commit marker installs this exact schedule. A stale or stopped marker therefore does nothing.
Cancel only while `context.currentTime < pendingIntroExit.transitionTime`:
```ts
const cancelPendingIntroExit = (): void => {
const pending = pendingIntroExit;
if (pending === null || context.currentTime >= pending.transitionTime) return;
pendingIntroExit = null;
pending.outro.cancel();
pending.main.cancel();
pending.commitMarker.cancel();
pending.mainStartMarker.cancel();
if (introProgramGain !== null) {
holdParameter(introProgramGain.gain, context.currentTime);
introProgramGain.gain.linearRampToValueAtTime(
1,
context.currentTime + 0.05,
);
}
};
let exitLoadRequest: Promise<void> | null = null;
const requestIntroExit = (requestedGeneration: number): void => {
if (closed || !introStarted || mainCommitted || pendingIntroExit !== null || exitLoadRequest !== null) return;
const introBuffer = loadedIntroBuffer;
const mainPromise = mainBufferPromise;
if (introBuffer === null || mainPromise === null) return;
exitLoadRequest = mainPromise.then((mainBuffer) => {
if (!closed && !latestChapter.atStart && !mainCommitted) {
scheduleIntroExit(introBuffer, mainBuffer, requestedGeneration);
}
}).catch(() => undefined).finally(() => {
exitLoadRequest = null;
});
};
```
`setChapterIntent()` always updates mixer mood. Before main commitment, non-Start intent requests one transition for its generation; Start intent cancels it. After commitment, Start changes only the mood to `open` and never touches sources or anchor.
- [ ] **Step 6: Schedule phase-aligned drive lift and the one-shot afterglow transition**
When the optional drive buffer resolves after main commitment, start it muted through the mixer's gate:
```ts
const startDriveLift = (
buffer: AudioBuffer,
schedule: MainSchedule,
): void => {
if (closed || driveLiftSource !== null) return;
const when = Math.max(
schedule.sourceStartTime,
context.currentTime + SOURCE_LEAD_SECONDS,
);
const source = makeSource(buffer, mixer.driveLiftInput);
source.node.loop = true;
source.node.loopStart = 0;
source.node.loopEnd = DRIVE_LIFT_LOOP_SECONDS;
source.node.start(
when,
phaseAlignedLoopOffset(
when,
schedule.musicalAnchorTime,
DRIVE_LIFT_LOOP_SECONDS,
),
);
driveLiftSource = source;
mixer.setDriveLiftAvailableAt(when);
};
```
Begin afterglow loading only after main playback is established. If it resolves before main ends, start one looping source whose initial pass includes the transition but whose loop excludes it:
```ts
const startAfterglow = (
buffer: AudioBuffer,
schedule: MainSchedule,
): void => {
if (closed || afterglowSource !== null || context.currentTime >= schedule.sourceEndTime) return;
const when = Math.max(
schedule.afterglowStartTime,
context.currentTime + SOURCE_LEAD_SECONDS,
);
if (when >= schedule.sourceEndTime) return;
const offset = Math.max(0, when - schedule.afterglowStartTime);
if (offset >= AFTERGLOW_LOOP_END_SECONDS) return;
const source = makeSource(buffer, mixer.bedInput);
source.node.loop = true;
source.node.loopStart = AFTERGLOW_LOOP_START_SECONDS;
source.node.loopEnd = AFTERGLOW_LOOP_END_SECONDS;
const progress = Math.min(1, offset / AFTERGLOW_TRANSITION_SECONDS);
source.gain.gain.setValueAtTime(progress, when);
source.gain.gain.linearRampToValueAtTime(1, schedule.sourceEndTime);
source.node.start(when, offset);
afterglowSource = source;
};
const beginOptionalLayers = (schedule: MainSchedule): void => {
if (closed || optionalScheduleAttached) return;
optionalScheduleAttached = true;
const drivePromise = driveLiftBufferPromise ?? loadOptional(sources.driveLift);
driveLiftBufferPromise = drivePromise;
void drivePromise.then((buffer) => {
driveLiftBufferPromise = null;
if (buffer !== null) startDriveLift(buffer, schedule);
});
const afterglowPromise = loadOptional(sources.afterglow);
afterglowBufferPromise = afterglowPromise;
void afterglowPromise.then((buffer) => {
afterglowBufferPromise = null;
if (buffer !== null) startAfterglow(buffer, schedule);
});
};
```
The main source `ended` listener clears `loadedMainBuffer`, `mainBufferPromise`, and its handle. The intro references clear after transition. The afterglow continues indefinitely and the transition bar never repeats.
- [ ] **Step 7: Route independent commands and restore authenticated fault behavior**
Implement:
```ts
setChapterIntent(intent) {
if (closed) return;
latestChapter = intent;
mixer.setChapterIntent(intent);
if (!mainCommitted && introStarted) {
if (intent.atStart) cancelPendingIntroExit();
else requestIntroExit(intent.generation);
}
},
setDriveEnergy(sample) {
if (closed) return;
latestDrive = sample;
driveTexture?.setEnergy(sample);
mixer.setDriveEnergy(sample);
},
```
Restore the old F10-F13 wrapper around `createSoundtrackEngine`: F10 throws before constructing a context; F11 rejects only a post-interruption resume; F12 produces `InvalidStateError` on that resume; F13 rejects suspend. Keep `recordTestFaultEvent()` calls and all existing browser-visible behavior unchanged.
- [ ] **Step 8: Make cancellation and close idempotent**
`cancelPending()` aborts fetches and cancels only pre-boundary intro work. `close()` must guard `closed`, abort requests, cancel pending markers, dispose texture and mixer, stop every remaining source, remove the context listener, disconnect output, and close the context once:
```ts
async close() {
if (closed) return;
closed = true;
loadController.abort();
cancelPendingIntroExit();
mixer.setDriveLiftAvailableAt(null);
driveTexture?.dispose();
mixer.dispose();
for (const source of [...ownedSources]) source.cancel();
ownedSources.clear();
context.removeEventListener('statechange', onStateChange);
stateListeners.clear();
outputGain.disconnect();
if (context.state !== 'closed') await context.close();
},
```
Ensure `cancelPendingIntroExit()` can run during close even after `closed = true`; it must not depend on the open flag.
- [ ] **Step 9: Run the focused graph and lifecycle checks**
Run:
```bash
npm run test:unit -- tests/unit/soundtrack-engine.test.ts tests/unit/adaptive-mixer.test.ts tests/unit/drive-texture.test.ts tests/unit/soundtrack-transport.test.ts tests/unit/audio-lifecycle.test.ts
npm run typecheck
```
Expected: all checks pass; optional failures are contained; main anchor never changes; Start exit is cancellable until its audible boundary; afterglow and drive lift share the clock.
- [ ] **Step 10: Commit the complete adaptive graph**
Run:
```bash
git add src/audio/soundtrack-engine.ts tests/helpers/fake-audio-context.ts tests/unit/soundtrack-engine.test.ts
git add tests/browser/audio-interruption.spec.ts
git commit -m "feat: add afterglow and synchronized drive lift"
```
If `tests/browser/audio-interruption.spec.ts` required no source edit, omit it from `git add`. Expected: one graph commit; large WAVs and external production files remain outside the repository.
### Task 10: Calibrate once, build the trusted preview, and run the final listening gate
**Files:**
- Modify only if listening requires calibration: `src/audio/adaptive-mixer.ts`
- Modify only if listening requires calibration: `src/audio/drive-texture.ts`
- Verify: all files changed in Tasks 3-9
- External output: `/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/verification/maximum-combined-mix.wav`
**Interfaces:**
- Consumes: the complete adaptive implementation and approved short masters.
- Produces: one technically screened maximum-state render, one authenticated browser regression invocation, one trusted compiled preview, one abuse/listening pass, and explicit user acceptance or actionable listening feedback.
- [ ] **Step 1: Render a conservative maximum-state headroom check**
Run this conservative offline ceiling using the exact maximum additive gains shipped in Task 8. It deliberately applies the maximum `+0.65 dB` shelf as full-band gain and overlays the afterglow transition against the louder lift phrase, so it overstates rather than understates the browser graph:
```bash
ffmpeg -v error -y \
-i /home/luca/Projects/nightdrive-music/generated/production/hybrid-v1/main-score-64bars-master-candidate-v4.wav \
-i /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/nightdrive-drive-lift.wav \
-i /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/nightdrive-afterglow-runtime.wav \
-f lavfi -i 'sine=frequency=62:sample_rate=48000:duration=20' \
-f lavfi -i 'sine=frequency=124:sample_rate=48000:duration=20' \
-f lavfi -i 'anoisesrc=color=pink:amplitude=0.1:sample_rate=48000:duration=20' \
-filter_complex '[0:a]atrim=start_sample=6429767:end_sample=7389767,asetpts=PTS-STARTPTS,volume=0.883718[bed];[1:a]aloop=loop=-1:size=535814,atrim=end_sample=960000,asetpts=PTS-STARTPTS,volume=0.17[lift];[2:a]atrim=start_sample=0:end_sample=133953,asetpts=PTS-STARTPTS,afade=t=in:ss=0:ns=133953:curve=tri,volume=0.883718[afterglow];[3:a]volume=0.012[engine0];[4:a]volume=0.0042[engine1];[5:a]highpass=f=180,lowpass=f=3400,volume=0.024[road];[bed][lift][afterglow][engine0][engine1][road]amix=inputs=6:duration=shortest:normalize=0[out]' \
-map '[out]' -ar 48000 -ac 2 -c:a pcm_f32le \
/home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/verification/maximum-combined-mix.wav
/home/luca/Projects/nightdrive-music/ACE-Step-1.5/.venv/bin/python /home/luca/Projects/nightdrive-music/skills/nightdrive-music/scripts/analyze_audio.py /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/verification/maximum-combined-mix.wav
ffmpeg -hide_banner -nostats -i /home/luca/Projects/nightdrive-music/generated/production/adaptive-v1/verification/maximum-combined-mix.wav -filter_complex 'ebur128=peak=true' -f null -
```
Expected: finite 48 kHz stereo output, sample peak below 1.0, and true peak below 0 dBFS despite the conservative overstatement. This is a technical ceiling check, not evidence that the mix sounds good; the actual browser graph is also checked audibly at volume 1 in Step 5. If it clips, reduce only `liftCap`, `MAX_ENGINE_GAIN`, or `MAX_ROAD_GAIN`; do not compress, limit, or reduce intro/main encoding quality.
- [ ] **Step 2: Run the final focused static checks once**
Run:
```bash
git diff --check
if rg -n "setJourneyState|AudioJourneyState|pendingAudioDestination|createMusicSequencer" src tests; then
echo 'Removed audio APIs remain in runtime or tests.' >&2
exit 1
fi
npm run test:unit -- tests/unit/audio-intent-coordinator.test.ts tests/unit/use-audio-intent.test.ts tests/unit/navigation-controller.test.ts tests/unit/section-controller.test.ts tests/unit/audio-lifecycle.test.ts tests/unit/drive-texture.test.ts tests/unit/adaptive-mixer.test.ts tests/unit/soundtrack-transport.test.ts tests/unit/soundtrack-engine.test.ts tests/unit/content-schemas.test.ts tests/integration/public-content.test.ts tests/integration/materialize-empty-asset-set.test.mjs tests/integration/verify-artifacts.test.ts tests/performance/budgets.test.ts
npm run typecheck
npm run verify:approved-assets
```
Expected: `git diff --check` prints nothing; the search prints no runtime references to removed combined/procedural APIs; all focused tests, typecheck, and exact asset verification pass.
- [ ] **Step 3: Run the one authenticated browser and compiled-build gate**
Run exactly one Playwright invocation:
```bash
npx playwright test tests/browser/audio-controls.spec.ts tests/browser/audio-interruption.spec.ts --project=desktop-chromium --workers=1
```
Expected: gesture creation, repeated activation, mute/volume, visibility, interruption, F10-F13 containment, and resource cleanup pass. The existing authenticated browser harness runs `npm run build:preview` for its test build and restores a clean trusted `dist` with the same command during teardown; do not run an additional standalone full build.
- [ ] **Step 4: Serve the compiled output through the established trusted path**
Run from the worktree and keep the process active:
```bash
./node_modules/.bin/vite preview --host 127.0.0.1 --port 4173 --strictPort
```
Expected: `http://127.0.0.1:4173/` renders the complete portfolio. Do not use `npm run dev`; it does not serve the prerendered production-input snapshot used by this site.
- [ ] **Step 5: Perform one browser abuse pass**
Use this single ordered pass while listening at a normal level and again briefly at volume 1:
1. Activate on Start and let intro-idle cross at least two loop seams; confirm no click, restart, volume hole, or drift.
2. Move a few pixels around the Start/Profile boundary, reverse, and pause on each side without going 6vh past it; confirm no chapter shade and no intro departure.
3. Move more than 6vh past the line and remain there for 400 ms; confirm one subtle 1.4-second `open` shade and one bar-safe intro departure.
4. Use the navigator rapidly for Start, Contact, and Toolkit; confirm the master stays continuous and only the latest settled Toolkit intent shades to `resolve`.
5. Interrupt a navigator trip with wheel or touch input; confirm the stale destination never becomes musical state.
6. Apply sustained strong physical scrolling for more than 450 ms; confirm immediate restrained engine/air response and drive lift only on the next beat. Release and confirm a smooth delayed one-beat exit.
7. Return to Start after main commitment; confirm the main continues and only the mood returns to `open`.
8. Resize the viewport and change content height while audio is active; confirm no mood change occurs from layout alone.
9. Mute, change volume, hide/show the page, and resume an interruption; confirm one live context and preserved transport.
10. Reload directly at `#contact`, activate there, wait through the remaining final phrase, then listen through three afterglow-idle repetitions; confirm the transition bar plays once and both loop seams disappear perceptually.
Record any audible issue with the section, action, and wall-clock time. Do not respond by adding a new transition system or seeking the master.
- [ ] **Step 6: Stop for the final in-site human listening checkpoint**
Tell the user the compiled preview is ready at `http://127.0.0.1:4173/` and summarize only:
- the approved afterglow and drive-lift seeds;
- whether the maximum-state render retained headroom;
- that tiny-scroll, rapid-jump, sustained-acceleration, return-to-Start, resize, lifecycle, and three-repeat afterglow scenarios passed;
- any remaining audible caveat.
Ask the user to approve the in-site result. Do not declare the soundtrack complete until approval. If the user reports a specific audible defect, adjust only the implicated profile/send/envelope or short asset, rerun that task's focused test and the affected listening excerpt, then return to this checkpoint.
- [ ] **Step 7: Commit only a calibration change, if one was required**
When Step 1 or the listening pass changed constants, run:
```bash
git add src/audio/adaptive-mixer.ts src/audio/drive-texture.ts tests/unit/adaptive-mixer.test.ts tests/unit/drive-texture.test.ts
git commit -m "fix: calibrate adaptive soundtrack balance"
```
If no calibration source changed, create no empty commit. Expected final status: no uncommitted implementation or asset changes, and the user has explicitly approved the compiled preview.
## Acceptance Trace
- Tiny-scroll stability: Tasks 4-6, verified in Task 10 steps 2 and 5.
- Rapid navigator supersession: Tasks 4-6, verified in Task 10 steps 2 and 5.
- Arbitrary Start dwell and cancellable departure: Task 9, verified in Task 10 step 5.
- Continuous main and return-to-Start behavior: Tasks 8-9, verified in Task 10 step 5.
- Infinite afterglow with a non-repeating transition: Tasks 2, 8, and 9, verified across three repetitions in Tasks 2 and 10.
- Four subtle moods: Tasks 5, 6, and 8, finalized by the Task 10 listening gate.
- Immediate drive texture and sustained drive lift: Tasks 5, 7, 8, and 9, finalized by the Task 10 listening gate.
- Responsive content/layout safety: Tasks 4-6, verified in Task 10 step 5.
- Gesture/lifecycle/failure/teardown safety: Tasks 6 and 9, verified by Task 10 step 3.
- Hash closure and 16 MiB delivery cap: Task 3, verified by Task 10 steps 2 and 3.