Downstream Soundtracks — handoff (start here to build v0)
✅ v0 BUILT 2026-07-15 — migration 489, PR (this session). What landed: the
downstream_soundtrackstable; the pure core (moodForPhase/pickForMood,downstream-soundtracks-core.ts, 10/10 tested); the service (getSoundtrackForPhasereusesimportFromUrl);/api/downstream/soundtracks/*; the glasses looper (apps/glasses/src/soundtrack.ts) that loops the bed and ducks under every nudge (duckForwired intospeakNudge), withnextReplayDelayMsmaking it correct whetherplayAudioresolves at end or immediately; voice control ("play/stop music"); and a dashboard "🎧 Soundtracks" panel (import + mood + preview). The one open call is still the on-device MIX-vs-INTERRUPT test below — the code handles both. Remaining after that: v1 mood playlists, v2 shared/synced group beds. The plan below is preserved as the build record.
Written 2026-07-15. Everything a fresh session needs to build the in-ear
soundtrack layer (task 1180ea1a, DOWNSTREAM_AUDIO_IDEAS.md §1).
The audio-source decision is made and — the happy surprise — the hard part (Suno
ingestion → durable storage) is already built for the EmberKiln Studio. This doc
is the reuse map + the one real unknown to verify on-device.
What it is (one paragraph)
Stream a soundtrack — a focus/mood bed, or a shared track piped to a group —
through the Downstream Glasses speaker, ducking automatically under JQ so the
music never talks over your own voice. It rides the same session.audio.playAudio
HTTPS-MP3 path the forward-voice nudge already uses, so the transport is free; the
new work is continuity (looping / next-track) and mixing (duck on nudge).
Decision: audio source = Suno, via the EXISTING ingestion
Curate tracks in Suno, ingest into our system. Do not build a new ingester — reuse the studio one, which already solves the durability gotcha (Suno rotates its CDN URLs, so we re-host).
Reusable service — apps/backend/src/services/creator-audio-library.ts:
importFromUrl({...})— paste a Suno share page (suno.com/song/<id>) or a directcdn1.suno.ai/*.mp3; it scrapes the embedded CDN URL and re-uploads to our storage so the asset survives Suno's URL rotation. This is the whole ballgame.storeUpload({...})— multipart MP3 upload (drag-drop from disk).listLibraryForUser/archiveLibraryTrack/renameLibraryTrack/setLibraryTrackTags.- Table
creator_audio_library(migrations 298 + 300-tags); 25 MB cap. - Route shape to copy:
apps/backend/src/routes/studio.ts/api/studio/audio-library/*(list / upload / import / rename / archive). - Frontend reference UI:
apps/frontend/src/app/studio/audio-library/page.tsx.
Licensing box to tick before anything public: Suno's commercial-use rights depend on the plan (paid tiers grant them; free doesn't). Fine for personal/dev now.
(The Graphene background music the user liked is the same lineage — proven aesthetic.)
Proposed v0 build
- Storage — reuse
importFromUrl/storeUploadfor the heavy lifting. For the soundtrack references, EITHER tagcreator_audio_libraryrows (downstream_soundtrack) OR add a thindownstream_soundtrackstable. Recommend the small table — the §3 ultradian tie-in wants Downstream-specific metadata (mood, maybe aphaseaffinity) that doesn't belong on the studio library. Suggested cols:id,user_id(nullable → curated/global),audio_url(the re-hosted MP3),title,mood(focus/calm/energize/ambient),loopable bool,source_url. - Backend —
/api/downstream/soundtracks(list + import-via-importFromUrl+ upload). AgetSoundtrackForPhase(userId)that picks a track by mood matching the current rhythm phase (callgetRhythm— peak→energize, trough→calm) is the bridge to §3's "adaptive audio." - Glasses player — a loop in
apps/glasses/src/session.ts: fetch a track URL,session.audio.playAudio({ audioUrl, volume })at low volume, replay on completion. Duck onspeakNudge: drop soundtrack volume (or pause) → play the nudge → restore. - App control — a toggle + track/mood picker on
/dashboard/downstream.
Phasing: v0 = one looping track ducked under nudges; v1 = mood playlists + voice control + phase-adaptive selection (§3); v2 = shared/synced group soundtrack (room- scoped via the JQ Bridge graph).
The one real unknown — VERIFY ON-DEVICE (Mentra Live)
session.audio.playAudio({ audioUrl, volume }) — note volume already works
(apps/glasses/src/watch.ts:99 plays a cue at
volume: 0.4). Two behaviors decide the ducking strategy and are NOT known from the
code — test on the actual glasses:
- Does a second
playAudioMIX with the first, or INTERRUPT it?- Mixes → duck = keep the soundtrack playing at low volume, play the nudge on top. Ideal.
- Interrupts → duck = pause/stop the soundtrack, play nudge, then restart the loop (accept a seam). Build the pause/resume path.
- Does
playAudioresolve when the track ENDS (so a loop = await → replay), or immediately (needs a duration timer to know when to replay)?
Everything else is standard; these two are the only "built blind" bits — mirror how the immersive/inspiration VR rooms handle hosted-MP3 playback if useful, but the glasses audio API is the source of truth.
File map / where things plug in
- Ingestion (reuse):
apps/backend/src/services/creator-audio-library.ts(importFromUrl,storeUpload) - Route shape (copy):
apps/backend/src/routes/studio.ts→/api/studio/audio-library/* - Glasses session + duck point:
apps/glasses/src/session.ts(speakNudge,attachJqSession), volume proof atapps/glasses/src/watch.ts:99 - Nudge audio the soundtrack must duck under:
sayInUserVoice→apps/backend/src/services/downstream-nudge.ts - Phase for adaptive selection (§3 bridge):
getRhythminapps/backend/src/services/downstream.ts - Product spec: DOWNSTREAM_AUDIO_IDEAS.md §1 (+ §3 for the adaptive tie-in)
Landmines / conventions
mainis protected — squash-merge via PR only; migration-gated PRs need the SQL applied to prod first (see CLAUDE.md).gh pr edit --bodyis blocked on this repo — set the PR body at create time.- Frontend preflight:
cd apps/frontend && npm run checkbefore a PR. - Backend tests: put the deterministic core (mood→phase mapping, loop/duck state
machine) behind a pure function + a jest test, per the
downstream-*/levers-corepattern (NODE_OPTIONS=--experimental-vm-modules npx jest <name>). - New cost-bearing cron? none needed for v0 (playback is client/glasses-driven).
- Keep docs in sync — INDEX.md + this doc +
DOWNSTREAM_AUDIO_IDEAS.md§1 status.