Skip to content

56 — First-play pipeline — tap → sound, guaranteed

Envisioning doc (perf/system). Governed by 00-ROADMAP.mddefer to it on any conflict. Created 2026-07-05 (owner-raised): "not much time ago we solved first byte… and now it's still laggy and flashy on that first load" — scenario: fresh install, account with data, opening a ~4-min song. Builds on: doc-24 §Performance (the whole first-byte saga lives there) · doc-22 (motion: "state transitions that pop are off-brand") · doc-37 (dashboard payload) · doc-38 (playback taxonomy) · doc-14 (Front Door/CDN) · doc-30 (global playback; favorites/offline groundwork, where relevant) · doc-57 (player-as-product behavior riding this pipeline).

Thesis: the June fixes were correct but live at call sites (opt-in discipline). Every surface shipped since then navigates to the player cold, including the MVP's core approval path. The fix is not another patch — it's making fast-first-play structural (impossible to skip) and measured (regressions show up in a chart, not in the owner's gut). Status: greenlit; S1 + S2 built 2026-07-06 (S1: backend b419748 + iOS; S2: iOS refactor, Front Door spike pending §7 Q4; xcodebuild green, ⏳ on-device QA — see CHANGELOG). S3–S4 remain.


1. The invariant we're buying

Tap a track anywhere → hear it, with one calm paint. Concretely:

  • TTFA (time-to-first-audio): tap → first AVPlayer.timeControlStatus == .playing. Budgets: ≤ 300 ms P50 warm (session already played something) · ≤ 1.5 s P50 / ≤ 3 s P95 fresh-install on Wi-Fi for a 4-min track.
  • One paint: the player renders once from seeds and fills in — nothing appears, disappears, or pops after first frame (doc-22 motion rule). Play is pressable immediately.
  • Every surface qualifies — not just the two rows that got the 2026-06 treatment.

2. What already shipped (and works — where it's wired)

The June 2026 pipeline, reconstructed from doc-24 §Performance + git:

PieceCommitWhat it does
Audio-stack warm-up at launchb3e750cLoads AVFoundation dylibs/daemon off-main (SonnanceApp.warmUpAudioStack)
Persisted File.duration2026-06-21Uploader probes locally, backend stores; player shows timecode instantly (new uploads only)
Optimistic skeleton from route seeds2026-06-21/26TrackRoute carries name/thumb; skeleton mirrors loaded layout (no cover-hero flash)
AudioPrewarmer — prewarm on tap0e0e0f8/63956e3Muted AVPlayer starts buffering the final audio the instant a row is tapped, keyed by fileId
Route-seeded install before detail2026-06-25seedAudio() installs the item from TrackRoute.audioFinalUrl on appear — playback isn't gated on GET /track/:id
Side-effects deferred off the push2026-06-26onAppearSetup() runs after first frame so the push never hitches

⚠️ Sharpened during the S1 build (2026-07-05): the prewarm + seed mechanism was correct but never actually fired — anywhere, ever. The doc-24 plan had a backend half ("populate audio in the list methods + add audioFinalUrl in serializeTrack") that was never built: every list payload (/track/project/:id, dashboard recents) always returned audio as the full populated array, while the iOS decoder (TrackAudioFinalRef, born in 0e0e0f8) expected a single File object on that key — and the permissive try? swallowed the mismatch. So audioFinalURL was always nil: AudioPrewarmer.prewarm guarded out silently, route seeds carried nil, and seedAudio() no-oped — on the "wired" surfaces too. Every first play since June has been the cold serial chain. The improvement the owner felt in June was real but came from the other rows of the table (skeleton, de-hitched push, persisted duration). Lesson locked into §5: a silent-fallback contract between repos needs an explicit field and a metric, not a try?.

3. Why it's laggy + flashy again — the audit (2026-07-05)

3.1 Coverage decayed — and the wire contract silently never matched

Every TrackRoute(...) call site at audit time ("wired" = call-site code present; in practice the wired rows also ran cold until S1, per §2 — their seeds decoded to nil):

SurfaceSeedsPrewarmFirst play
Project track rows — ProjectTracksView.swift:403✅ wired (nil at runtime)✅ wired (nil at runtime)cold until S1
Home Resume tile — DashboardView.swift:458✅ wired (nil at runtime)✅ wired (nil at runtime)cold until S1
Home approval rows — DashboardView.swift:505fully cold — this is the Review & Approve MVP path
Artist portal → track — ArtistDetailView.swift:286❌ (id only)fully cold
Project portal → track — ProjectTracksView.swift:361❌ (images only)fully cold
Router "New version" — RootTabsView.swift:131✅ wired– (upload intent)fine
Create-track landing — ProjectTracksView.swift:319– (no audio yet)n/a

A cold surface pays the serial chain the June work was built to kill: push → skeleton → GET /track/:idthen build the item → then TLS + first bytes. On a fresh install that chain is worst-case everywhere (cold DNS/TLS to blob, empty caches, dashboard artwork burst competing for bandwidth).

Backend root cause for the approval rows: the dashboard composes tracks from findAllByUser / findAllSharedWithUser (track.service.ts:73 / :408) which don't populate audio — the client couldn't seed even if it wanted to. Recents populate fully (via findOneById), which is why the Resume tile works. The kicker: buildPendingApprovals (mobile.service.ts:103) already fetches each under-review file (id, blobUrl, duration) for who·when — and then drops it from the payload. The data is in hand; it's one field away.

3.2 The "flashy" inventory (cold path)

  1. The transport bar doesn't exist until detail landsPlayerView.swift:1315 gates bottomGlassStack on viewModel.detail, and it enters with a move+fade (:1986). So the user cannot press play during the fetch, then the controls pop in. This is both the lag (perceived) and the flash.
  2. Duration/timecode arrives late — seeds pass knownDuration: nil (seedAudio, PlayerView.swift:1040); TrackAudioFinalRef (Track.swift:166) decodes only _id + blobUrl even though the backend has File.duration since 06-21. Cold paths re-probe over the network.
  3. Skeleton → content swap — mitigated 06-26 (skeleton mirrors layout) but on cold paths the whole middle (version label, approve chips, comments) still lands as one late swap.
  4. Buffering spinner — pressing play before the buffer is ready toggles isBuffering (PlayerView.swift:1132) → spinner blink even when prewarmed-but-not-quite-ready.

3.3 Costs the June work never addressed

  • No preconnect: warmUpAudioStack loads dylibs but never touches the network; the first play of the install pays DNS + TCP + TLS to the blob host inside the tap.
  • No audio cache, at all: every open re-streams — replaying the same 4-min song five minutes later is another full fetch. (SAS URLs live 1 yearblob-storage.service.ts:114 — so cached bytes keyed by fileId would stay valid; today we throw them away.)
  • Original-fidelity masters: no renditions — a 4-min WAV ≈ 40 MB. With automaticallyWaitsToMinimizeStalling = true + plain play(), AVPlayer waits until it predicts playthrough → seconds on LTE before sound.
  • Nothing measures TTFA: doc-38's taxonomy has play/pause/seek/complete — but not the one number this whole saga is about. "It got worse" is currently a feeling.

4. The envisioned system — the First-Play Pipeline

Four stages, each with an owner: Intent → Warm → Paint → Sound.

4.1 Intent — where does "the user wants this track" live?

ForkProsCons
A. Call-site discipline (status quo)No refactorProven to decay — 3 of 7 surfaces already cold; every future surface is a coin flip
B. Route-level: TrackRoute(track:) is the only way inCompiler-enforced seeds; prewarm fires inside the one navigation helper; new surfaces are warm by constructionSmall refactor of 7 call sites; payloads must carry the audio ref (§4.5)
C. Global player-coordinator (queue/Spotify-style)Also buys queue, mini-player continuityOverkill pre-MVP; big refactor; doesn't help paint. (That layer is post-MVP — doc-30 Phase 2 + doc-57; it rides on top of B, not instead of it)

Recommendation: B. Replace the free-form memberwise init with TrackRoute(track: Track, …) + TrackRoute(id:) marked explicitly as .coldOpen(id:) so a cold route is a visible code smell, not a default. Prewarm moves out of simultaneousGesture sprinkles into the one place routes are appended.

4.2 Warm — how early do bytes move?

ForkProsCons
A. On tap (today)Zero wasteWarm window = push animation only (~350 ms); first tap of the install still pays TLS inside it
B. On likelihood ⭐ — when the dashboard payload lands, prewarm the Resume tile's track + first approval row (top-2 candidates, maxEntries=2 already bounds this); rows additionally prewarm on tap as todayFirst-of-install play is warm before the tap (this also is the TLS preconnect — a URLSession preconnect wouldn't warm AVFoundation's stack; prewarming a real item does); biggest TTFA cut for the exact owner scenarioSpeculative bytes; needs a guard
C. Prewarm everything visibleMax hit rateCellular data + blob egress waste; scroll churn

Recommendation: B, guarded: full prewarm on Wi-Fi; on cellular limit the speculative preroll (preferredForwardBufferDuration ≈ 5 s) so likelihood-warming costs ~KB not MB. Tap-prewarm stays unlimited (that's expressed intent).

4.3 Paint — one calm frame (kills "flashy")

  • Seeded transport: render bottomGlassStack from seeds (track name, thumb, play button) the moment the view appears; play enables as soon as the seeded item installs — i.e. usually immediately, before /track/:id returns. Approve chips/version label keep their skeleton slots and fade in with detail — nothing moves or pops (doc-22).
  • Seeded duration: extend TrackAudioFinalRef to decode duration, thread it through TrackRouteseedAudio(knownDuration:) → timecode is right on first frame. Legacy files (pre-06-21, no stored duration): when the player probes one, write it back (PATCH /track/file metadata) so each legacy file is slow at most once, org-wide.
  • Sound-before-chrome: if the user hits play while isBuffering, prefer playImmediately(atRate: 1.0) — start the instant frames exist rather than waiting for the full playthrough prediction (spinner shows only on an actual stall).

4.4 Sound — the bytes themselves

ForkProsCons
A. Progressive original (today)Fidelity = the master; zero infra4-min WAV ≈ 40 MB; LTE first plays stay heavy
B. Disk cache keyed by fileId ⭐ (slice 3)Repeat plays instant + free; 1-year SAS makes it trivial (download-after-first-successful-play, LRU cap ~500 MB); groundwork for doc-30/57 favorites/offlineDoesn't speed the very first play of a file
C. Server renditions (AAC preview / HLS via ffmpeg worker; AMS is retired)Only lever that shrinks the first-play payload; HLS also unlocks proper ABRNew infra + product question — this is a review tool: do reviewers approve a preview? Fidelity stance needed
D. Front Door in front of audio blobs (doc-14 already deployed for images)Config-level; cuts TLS+geo latency for every fetchDoesn't change payload size; egress cost review

Recommendation: B now (slice 3), D as a slice-2 spike, C deferred until the metric (§5) tells us size — not connection — is the dominant term. If C ever ships: "Stream" rendition by default, one-tap "Master" toggle in the player, master remains the approval object of record.

5. The guarantee — how it stays solved

The June fix regressed silently because nothing watched it. Two locks:

  1. Structural (can't skip): §4.1-B — seeds + prewarm live in the route/navigation layer; a surface without them doesn't compile (or is loudly .coldOpen). The doc-24 pattern of per-row simultaneousGesture sprinkles is retired.
  2. Measured (can't hide): extend doc-38 with playback_first_audio — fired once per player session on the first transition to .playing: { ttfaMs, surface, seeded, prewarmed, cacheHit, coldLaunch, network, fileBytes?, fileKind }.
    • Budgets: warm P50 ≤ 300 ms · fresh-install Wi-Fi P50 ≤ 1.5 s / P95 ≤ 3 s.
    • DEBUG HUD: in debug builds, log/overlay the TTFA on every open — the owner sees the number during dev instead of sensing it weeks later.
    • A budget breach in the analytics slice = a bug, filed like one. "Laggy again" becomes a graph with a date on it — and the date points at the commit.

6. Rollout slices (each independently shippable)

  • S1 — Cover the holes + see the number (small; backend + iOS — built 2026-07-06; backend in b419748. One deviation: the artist-portal nav stays a labeled cold-open (surface: "artist.portal") — no track data exists at that call site; warming it means carrying an audio ref through the portal rollup → S2 candidate.)QA crash found + fixed 2026-07-06 (owner device run): adopting the prewarmed item crashed intermittently — NSInvalidArgumentException: "An AVPlayerItem cannot be associated with more than one instance of AVPlayer". Root cause: AudioPrewarmer.take() moved the actively-loading item between players (detach → attach races AVFoundation's internal association) — a latent June design flaw that S1 exposed, since this path had never executed before (§2). Fix: take() now returns a fresh AVPlayerItem over the warmed AVURLAsset — the asset keeps the resolved header state + hot connection (the cold-TLS cost prewarm exists to kill); the item-level buffer is re-pulled over the warm connection (bounded re-fetch, never a crash). xcodebuild green; ⏳ re-QA. Backend: attach the already-fetched under-review file to the approvals payload (review.file = {id, blobUrl, duration} in mobile.service.ts); populate a leanaudio-final ref (_id blobUrl duration) on the owned/shared list queries; add duration to the existing list populate used by /track/project/:id. iOS: decode duration in TrackAudioFinalRef; seed the three cold surfaces (approval rows, both portal navs); playback_first_audio + DEBUG log. → The owner's literal scenario (fresh install → Home → approval/track) goes warm, and TTFA starts recording.
  • S2 — Make it structural + earlier (iOS refactor — built 2026-07-06, same-day owner "continue"; xcodebuild green, ⏳ QA)TrackRoute(track:) single entry + route-level prewarm (§4.1-B): the memberwise route init is now private — surfaces use TrackRoute(track:) (seed-complete by construction) or the loud .coldOpen(id:surface:); prewarm lives in TrackLink (rows) + NavigationPath.push(_:) (imperative pushes) — the per-row simultaneousGesture sprinkles are retired. Likelihood- prewarm with the cellular guard (§4.2-B): dashboard payload lands → top-2 candidates (Resume + first approval row) prewarm speculatively; NWPathMonitor caps expensive-path preroll at ~5 s. Seeded transport + playImmediately (§4.3): bottomGlassStack renders from seeds before/track/:id (play pressable from the first frame; detail fills in place), and user-initiated play uses playImmediately(atRate: 1.0). Pending from S2's original scope: the Front Door-over- audio-blobs spike (§4.4-D) — infra config + the §7 Q4 egress-cost call, owner's court.
  • S3 — Never fetch twice: fileId-keyed disk cache (LRU ~500 MB, download-after-first-play), legacy-duration write-back.
  • S4 — Renditions decision (data-driven): after ~30 days of TTFA, decide §4.4-C with the owner (fidelity stance), only if fileBytes dominates the P95.

7. Open questions for the owner

  1. Cellular speculation: OK with ~5 s of speculative preroll for the top-2 likely tracks on cellular, or Wi-Fi-only?
  2. Cache cap: 500 MB LRU for audio feel right for reviewer devices?
  3. Fidelity stance (only if S4 triggers): is a high-bitrate AAC "stream" acceptable as the default listening experience when the master stays one tap away — for a review product?
  4. Front Door egress in front of audio blobs — comfortable with the cost profile at current scale? ✅ Answered 2026-07-06 (owner): DEFERRED. Owner's cost signal: "last time I was spending €40 for no use in dev state." Rationale: Front Door = fixed monthly base + egress markup, and it only buys geo-edge latency — S1/S2's prewarm already hides the TLS cost on the common paths, and S3's disk cache (free) covers repeat plays. The playback_first_audio metric exists precisely to show if connection latency ever dominates — revisit at TestFlight scale with real geographic users, data-driven like S4. The §4.4-D spike is closed-as-deferred; S2 is complete.

Ctrl-Audio Platform Documentation