From 2b87389b3cca858e958c3a2f764637ecac45a49c Mon Sep 17 00:00:00 2001 From: mfwolffe Date: Fri, 27 Mar 2026 18:01:36 -0400 Subject: [PATCH 1/9] source bassline from Bassline assignment, not Melody's preferredSample The previous fix used preferredSample which resolves to the Melody part's sample_audio (the student's assignment). The bassline needs to come from the Bassline assignment for the same piece, found via activities Redux state. Also fixes race condition where assignment being null on first render caused the loading gate to pass through, mounting DAWProvider with empty tracks. --- .../[partType]/activity/[step].js | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/pages/courses/[slug]/[piece]/[actCategory]/[partType]/activity/[step].js b/pages/courses/[slug]/[piece]/[actCategory]/[partType]/activity/[step].js index 84eecb9..541facf 100644 --- a/pages/courses/[slug]/[piece]/[actCategory]/[partType]/activity/[step].js +++ b/pages/courses/[slug]/[piece]/[actCategory]/[partType]/activity/[step].js @@ -281,11 +281,15 @@ export default function ActivityPage() { completedOpsArray: [...completedOps] // Expand the array to see actual values }); - // Pre-populate bassline track for Activity 3 using the assignment's sample audio - const basslineName = assignment?.part?.piece?.name - ? `${assignment.part.piece.name} - Bassline` + // Pre-populate bassline track for Activity 3 from the Bassline assignment + // (not from preferredSample, which is the Melody part's sample) + const basslineAssignment = loadedActivities + ? activities[piece]?.find((a) => a.part_type === 'Bassline') + : null; + const basslineURL = basslineAssignment?.part?.sample_audio || null; + const basslineName = basslineAssignment?.part?.piece?.name + ? `${basslineAssignment.part.piece.name} - Bassline` : 'Bassline'; - const basslineURL = preferredSample || null; const initialTracks = (stepNumber === 3 && basslineURL) ? [{ name: basslineName, type: 'audio', @@ -305,11 +309,10 @@ export default function ActivityPage() { }], }] : []; - // Wait for activity progress to load, and for Activity 3 also wait for the - // sample audio URL so initialTracks is populated before MultitrackProvider mounts - // (useState only reads initialTracks on first mount — late arrivals are ignored) - const awaitingSample = stepNumber === 3 && !preferredSample && !!assignment; - if (isLoading || awaitingSample) { + // Wait for activity progress and activities list to load before mounting + // DAWProvider — useState(initialTracks) only reads on first mount + const awaitingBassline = stepNumber === 3 && !loadedActivities; + if (isLoading || awaitingBassline) { return (
From 572e790d02120db9d64143df2153203042b84a0d Mon Sep 17 00:00:00 2001 From: mfwolffe Date: Tue, 31 Mar 2026 19:14:02 -0400 Subject: [PATCH 2/9] await logOperation calls to prevent concurrent backend requests --- .../audio/DAW/CustomWaveform/CustomTimeline.jsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/components/audio/DAW/CustomWaveform/CustomTimeline.jsx b/components/audio/DAW/CustomWaveform/CustomTimeline.jsx index c57d057..5edd740 100644 --- a/components/audio/DAW/CustomWaveform/CustomTimeline.jsx +++ b/components/audio/DAW/CustomWaveform/CustomTimeline.jsx @@ -79,8 +79,8 @@ export default function CustomTimeline() { // Log for study protocol (retain/scissor operation) if (logOperation) { - logOperation('clip_cut', { start: activeRegion.start, end: activeRegion.end }); - logOperation('region_retained', { start: activeRegion.start, end: activeRegion.end }); + await logOperation('clip_cut', { start: activeRegion.start, end: activeRegion.end }); + await logOperation('region_retained', { start: activeRegion.start, end: activeRegion.end }); } } catch (error) { @@ -119,18 +119,19 @@ export default function CustomTimeline() { await applyProcessedAudio(cutBuffer); // Log for study protocol (delete operation) + // Operations must be awaited sequentially to avoid backend race conditions if (logOperation) { console.log('🎯 Logging clip_delete operation:', { start: activeRegion.start, end: activeRegion.end }); - logOperation('clip_delete', { start: activeRegion.start, end: activeRegion.end }); + await logOperation('clip_delete', { start: activeRegion.start, end: activeRegion.end }); // Also log silence trimming if applicable if (isStartTrim) { console.log('🎯 Logging silence_trimmed_start operation'); - logOperation('silence_trimmed_start', { region: activeRegion }); + await logOperation('silence_trimmed_start', { region: activeRegion }); } if (isEndTrim) { console.log('🎯 Logging silence_trimmed_end operation'); - logOperation('silence_trimmed_end', { region: activeRegion }); + await logOperation('silence_trimmed_end', { region: activeRegion }); } } else { console.warn('⚠️ logOperation is not available for clip_delete'); @@ -195,15 +196,15 @@ export default function CustomTimeline() {
From 4ba0407d9184f382a4b7e618653e6c0c026e840e Mon Sep 17 00:00:00 2001 From: mfwolffe Date: Tue, 31 Mar 2026 20:42:38 -0400 Subject: [PATCH 7/9] resume suspended AudioContext on transport play --- components/audio/DAW/Multitrack/AudioEngine.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/components/audio/DAW/Multitrack/AudioEngine.js b/components/audio/DAW/Multitrack/AudioEngine.js index 6d7695e..ee97c59 100644 --- a/components/audio/DAW/Multitrack/AudioEngine.js +++ b/components/audio/DAW/Multitrack/AudioEngine.js @@ -93,6 +93,10 @@ export function createTransport({ onTick, getProjectDurationSec }) { }, play(fromSec = null) { if (fromSec != null) state.startAtSec = Math.max(0, fromSec); + // Resume suspended AudioContext (browsers block auto-created contexts) + if (audioContext.state === 'suspended') { + audioContext.resume(); + } state.contextStartTime = audioContext.currentTime; state.isPlaying = true; cancelAnimationFrame(state.rafId); From ca00fa6099668f65a594f90b242667b03f5c8b0d Mon Sep 17 00:00:00 2001 From: mfwolffe Date: Tue, 31 Mar 2026 20:56:46 -0400 Subject: [PATCH 8/9] =?UTF-8?q?fix=20clip=20dragging=20in=20select=20mode?= =?UTF-8?q?=20=E2=80=94=20initialize=20drag=20state=20on=20pointerdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contexts/TrackClipCanvas.jsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/contexts/TrackClipCanvas.jsx b/contexts/TrackClipCanvas.jsx index 71ab0db..dfd7f67 100644 --- a/contexts/TrackClipCanvas.jsx +++ b/contexts/TrackClipCanvas.jsx @@ -429,15 +429,12 @@ export default function TrackClipCanvas({ track, zoomLevel = 100, height = 100, // Handle select tool if (editorTool === 'select') { - console.log('🔶 TrackClipCanvas: Select tool click', { trackId: track.id, hitIndex: hit.index, clipCount: clips.length }); if (hit.index >= 0) { // Clicked on a clip - handle selection const c = clips[hit.index]; const isShift = e.shiftKey; const isCtrl = e.ctrlKey || e.metaKey; - console.log('🔶 TrackClipCanvas: Clicked on clip', { clipId: c.id, isShift, isCtrl }); - if (isShift || isCtrl) { // Add to or toggle from selection if (selectedClipIds.includes(c.id)) { @@ -449,18 +446,21 @@ export default function TrackClipCanvas({ track, zoomLevel = 100, height = 100, // Single select (replace selection) setSelectedClipId(c.id); setSelectedClipIds([c.id]); - // Set track as selected when doing single selection setSelectedTrackId(track.id); } + // Initialize drag so the clip can be moved in one click-drag motion + const op = hit.edge === 'L' ? 'resizeL' : hit.edge === 'R' ? 'resizeR' : 'move'; + dragRef.current.op = op; + dragRef.current.clipIndex = hit.index; + dragRef.current.startX = e.clientX; + dragRef.current.orig = { start: c.start || 0, duration: c.duration || 0, offset: c.offset || 0 }; + // Stop propagation so SelectionOverlay doesn't interfere e.stopPropagation(); return; - } else { - console.log('🔶 TrackClipCanvas: Clicked on empty space, letting SelectionOverlay handle it'); } - // Note: Selection box dragging is now handled by SelectionOverlay component - // which operates at the container level for cross-track selection + // Empty space click — let SelectionOverlay handle it return; } From 9f4c22091aa2e5f205a6369c5331b06896b81bb1 Mon Sep 17 00:00:00 2001 From: mfwolffe Date: Tue, 31 Mar 2026 21:18:39 -0400 Subject: [PATCH 9/9] implement non-destructive clip trimming with buffer bounds clamping Edge-drag on clips now behaves like standard DAWs (trim, not stretch): - resizeL: moves the in-point into the buffer, can't reveal past offset 0 - resizeR: extends the out-point, clamped to the source audio buffer length - Clips store sourceDuration (total audio buffer length) for bounds checking - sourceDuration set on all clip creation paths: import, recording, auto-init - Split clips inherit sourceDuration from the parent via spread --- .../audio/DAW/Multitrack/RecordingTrack.js | 1 + .../audio/DAW/Multitrack/TakesImportModal.js | 2 ++ components/audio/DAW/Multitrack/Track.js | 1 + contexts/MultitrackContext.js | 1 + contexts/TrackClipCanvas.jsx | 20 +++++++++++++++++-- 5 files changed, 23 insertions(+), 2 deletions(-) diff --git a/components/audio/DAW/Multitrack/RecordingTrack.js b/components/audio/DAW/Multitrack/RecordingTrack.js index aafd59b..812189a 100644 --- a/components/audio/DAW/Multitrack/RecordingTrack.js +++ b/components/audio/DAW/Multitrack/RecordingTrack.js @@ -253,6 +253,7 @@ export default function RecordingTrack({ track, index, zoomLevel = 100 }) { id: `clip-${track.id}-${Date.now()}`, start: recordingStartPosition, // Use the stored start position duration: audioDuration, + sourceDuration: audioDuration, // total buffer length for trim clamping src: url, offset: 0, color: track.color || '#ff6b6b', diff --git a/components/audio/DAW/Multitrack/TakesImportModal.js b/components/audio/DAW/Multitrack/TakesImportModal.js index 02d592a..ea260cd 100644 --- a/components/audio/DAW/Multitrack/TakesImportModal.js +++ b/components/audio/DAW/Multitrack/TakesImportModal.js @@ -127,6 +127,7 @@ export default function TakesImportModal({ show, onHide, takes = [] }) { const finalClip = { ...placeholderClip, duration: result.duration, + sourceDuration: result.duration, // total buffer length for trim clamping isLoading: false, loadingState: 'complete', processingMethod: result.method @@ -159,6 +160,7 @@ export default function TakesImportModal({ show, onHide, takes = [] }) { const finalClip = { ...placeholderClip, duration: duration, + sourceDuration: duration, // total buffer length for trim clamping isLoading: false, loadingState: 'complete', processingMethod: 'fallback' diff --git a/components/audio/DAW/Multitrack/Track.js b/components/audio/DAW/Multitrack/Track.js index b405844..3ac0771 100644 --- a/components/audio/DAW/Multitrack/Track.js +++ b/components/audio/DAW/Multitrack/Track.js @@ -430,6 +430,7 @@ export default function Track({ track, index, zoomLevel = 100 }) { id: `clip-${track.id}-${Date.now()}`, start: recordingStartPosition, // Use the stored start position duration: audioDuration, + sourceDuration: audioDuration, // total buffer length for trim clamping src: url, offset: 0, color: track.color || '#ff6b6b', diff --git a/contexts/MultitrackContext.js b/contexts/MultitrackContext.js index cd95804..09661c7 100644 --- a/contexts/MultitrackContext.js +++ b/contexts/MultitrackContext.js @@ -175,6 +175,7 @@ export const MultitrackProvider = ({ children, initialTracks = [] }) => { id: `clip-${track.id}`, start: 0, duration: d, + sourceDuration: d, // total buffer length for trim clamping color: track.color || '#7bafd4', src: track.audioURL, offset: 0, diff --git a/contexts/TrackClipCanvas.jsx b/contexts/TrackClipCanvas.jsx index dfd7f67..6a027dc 100644 --- a/contexts/TrackClipCanvas.jsx +++ b/contexts/TrackClipCanvas.jsx @@ -21,7 +21,7 @@ export default function TrackClipCanvas({ track, zoomLevel = 100, height = 100, } = useMultitrack(); const canvasRef = useRef(null); - const dragRef = useRef({ op: null, clipIndex: -1, startX: 0, pxPerSecCSS: 1, orig: null }); + const dragRef = useRef({ op: null, clipIndex: -1, startX: 0, pxPerSecCSS: 1, orig: null, sourceDuration: null }); // selectionBoxRef removed - selection box now handled by SelectionOverlay component const [peaksCache, setPeaksCache] = useState(new Map()); // clip.id -> peaks const clips = Array.isArray(track?.clips) ? track.clips : []; @@ -455,6 +455,7 @@ export default function TrackClipCanvas({ track, zoomLevel = 100, height = 100, dragRef.current.clipIndex = hit.index; dragRef.current.startX = e.clientX; dragRef.current.orig = { start: c.start || 0, duration: c.duration || 0, offset: c.offset || 0 }; + dragRef.current.sourceDuration = c.sourceDuration || null; // Stop propagation so SelectionOverlay doesn't interfere e.stopPropagation(); @@ -514,6 +515,7 @@ export default function TrackClipCanvas({ track, zoomLevel = 100, height = 100, dragRef.current.clipIndex = hit.index; dragRef.current.startX = e.clientX; dragRef.current.orig = { start: c.start || 0, duration: c.duration || 0, offset: c.offset || 0 }; + dragRef.current.sourceDuration = c.sourceDuration || null; } else { dragRef.current.op = null; dragRef.current.clipIndex = -1; @@ -546,6 +548,7 @@ export default function TrackClipCanvas({ track, zoomLevel = 100, height = 100, const dxSecRaw = dxCss / dragRef.current.pxPerSecCSS; const dxSec = snapEnabled ? quantize(dxSecRaw) : dxSecRaw; const { start, duration: dur, offset } = dragRef.current.orig; + const srcDur = dragRef.current.sourceDuration; // total audio buffer length const op = dragRef.current.op; let newStart = start; let newDur = dur; @@ -554,12 +557,25 @@ export default function TrackClipCanvas({ track, zoomLevel = 100, height = 100, if (op === 'move') { newStart = Math.max(0, start + dxSec); } else if (op === 'resizeL') { + // Trim from left: advance offset into the buffer, can't go past offset 0 newStart = Math.max(0, start + dxSec); const delta = newStart - start; - newDur = Math.max(MIN_DUR, dur - delta); newOffset = Math.max(0, (offset || 0) + delta); + newDur = Math.max(MIN_DUR, dur - delta); + // Clamp: can't reveal audio before the buffer start + if (newOffset < 0) { + const correction = -newOffset; + newOffset = 0; + newStart += correction; + newDur -= correction; + } } else if (op === 'resizeR') { newDur = Math.max(MIN_DUR, dur + dxSec); + // Clamp: can't extend past the end of the source audio buffer + if (srcDur != null) { + const maxDur = srcDur - (newOffset || offset || 0); + newDur = Math.min(newDur, maxDur); + } } draw();