Skip to content

Fix Activity 3 bassline import, media proxying, and multitrack editor interactions#156

Merged
hcientist merged 4 commits intoLab-Lab-Lab:mainfrom
espadonne:fix/activity3-bassline-source
Apr 2, 2026
Merged

Fix Activity 3 bassline import, media proxying, and multitrack editor interactions#156
hcientist merged 4 commits intoLab-Lab-Lab:mainfrom
espadonne:fix/activity3-bassline-source

Conversation

@mfwolffe
Copy link
Copy Markdown
Collaborator

@mfwolffe mfwolffe commented Apr 1, 2026

Summary

Activity 3's bassline import was broken in prod — students could not load the bassline into the multitrack editor. The root cause was a chain of issues: wrong data source for the audio URL, CORS blocking fetch()/decodeAudioData() on cross-origin media files, and several multitrack editor interaction bugs.

High-risk changes

/media/ proxy via Next.js rewrite (next.config.js)

The backend's sample_audio FileField serializes as an absolute URL pointing to the API host (e.g. https://dev-api.musiccpr.org/media/sample_audio/...). The browser's <audio> element loads these cross-origin fine, but fetch() and decodeAudioData() — used by the import pipeline to decode audio into a buffer for waveform rendering — require CORS headers that the backend doesn't serve for /media/ paths.

Rather than adding CORS configuration to the backend (environment-specific, fragile), media requests are now proxied through Next.js using the same rewrite pattern already established for /backend/*:

{ source: '/media/:rest*', destination: `${NEXT_PUBLIC_BACKEND_HOST}/media/:rest*` }

The bassline's absolute URL is stripped to a relative /media/... path so it routes through the proxy. This works in all environments without backend changes.

assertResponse now includes response body (api.js)

Error messages from the API layer now include the backend's response body (e.g. 400: Bad Request — {"error":"database is locked"}) instead of just the HTTP status text. This is a change to the shared makeRequest path — assertResponse is now async.

Bassline import flow

  • Data source: Bassline sourced from the Bassline assignment for the same piece via activities[piece]?.find(a => a.part_type === 'Bassline')?.part?.sample_audio, not from the Melody assignment's preferredSample
  • Approach: Bassline injected as a named sample take in the "Import Takes" modal instead of pre-populating initialTracks (which had useState timing issues)
  • Duration preview: Modal resolves actual audio duration via Audio.loadedmetadata for takes that report 0:00
  • DAW visibility: DAWInitializer component sets showDAW=true and dawMode='multi' for Activities 3-4 (previously both defaulted to false/single)

Activity operation logging

  • logOperation calls in CustomTimeline.jsx are now awaited sequentially to prevent concurrent backend requests (caused database is locked on SQLite, stale step_completions overwrites on PostgreSQL)
  • Logging errors caught separately from audio processing errors — a failed log no longer shows "Failed to cut region" or blocks the UI

Multitrack editor fixes

  • Playback cursor stuck at 0s: Transport's AudioContext was suspended; added audioContext.resume() on play
  • Clip dragging broken in select mode: onPointerDown selected the clip but returned before initializing drag state; now initializes drag in the same handler
  • Non-destructive clip trimming: Edge-drag on clips now enforces source audio buffer bounds — resizeL clamps at offset=0, resizeR clamps at sourceDuration - offset. Clips store sourceDuration for bounds checking, with fallback to the decoded AudioBuffer's duration from the waveform cache
  • Waveform rendering rewrite: Per-pixel min/max computed directly from the decoded AudioBuffer at draw time (following Audacity's approach), replacing the broken intermediate peak cache that caused stretching and dropped regions

Resize clamping:
- resizeL: can't drag past offset=0 (buffer start)
- resizeR: can't extend past sourceDuration-offset (buffer end)
- If sourceDuration is unknown, defaults to offset+duration (no extending)

Waveform rendering:
- Peaks drawn at 1 peak = 1 pixel instead of stretching across clip width
- Audio portion renders accurately; excess clip area beyond audio is empty
- Center line still spans full clip width for visual bounds
Replaces the broken getPeaksForClip approach (which returned peaks at a
cached resolution that didn't match pixel width, causing stretch/drop
artifacts) with direct per-pixel computation from the decoded AudioBuffer.

Follows the approach used by Audacity, wavesurfer.js, and waveform-playlist:
- Cache decoded AudioBuffer per source URL (not pre-computed peaks)
- At draw time, compute samplesPerPixel = durationSamples / pixelWidth
- For each pixel column, find min/max from the corresponding sample range
- Use Audacity's rounding pattern (round(col*spp) to round((col+1)*spp))
  to avoid cumulative drift across columns
- Apply Audacity's gap-filling between adjacent columns for visual continuity
@mfwolffe mfwolffe requested a review from hcientist April 2, 2026 00:51
@hcientist hcientist merged commit 5c386d4 into Lab-Lab-Lab:main Apr 2, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants