From e3a0e28788da12e2237070fcee5d1dd9a4ee78d8 Mon Sep 17 00:00:00 2001 From: espadonne Date: Tue, 31 Mar 2026 21:29:27 -0400 Subject: [PATCH 1/4] fix clip edge-drag: enforce buffer bounds, prevent waveform stretch 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 --- contexts/TrackClipCanvas.jsx | 96 +++++++++++++++++------------------- 1 file changed, 44 insertions(+), 52 deletions(-) diff --git a/contexts/TrackClipCanvas.jsx b/contexts/TrackClipCanvas.jsx index 6a027dc..ca5b68e 100644 --- a/contexts/TrackClipCanvas.jsx +++ b/contexts/TrackClipCanvas.jsx @@ -150,89 +150,84 @@ export default function TrackClipCanvas({ track, zoomLevel = 100, height = 100, drawLoadingState(ctx, clip, rect, dpr); return; } - + const peaks = peaksCache.get(clip.id); if (!peaks || peaks.length === 0) return; - + const clipH = rect.h; const centerY = rect.y + clipH / 2; const amplitude = (clipH - 12 * dpr) / 2; // Leave some padding - - // Calculate how many peaks to draw per pixel - const peaksPerPixel = peaks.length / rect.w; - + + // Determine actual audio width — peaks represent the real audio content. + // Map 1 peak = 1 pixel at current zoom so waveform never stretches. + const audioW = Math.min(rect.w, peaks.length); + ctx.save(); - + // Set up clipping region to contain waveform within clip bounds ctx.beginPath(); ctx.rect(rect.x, rect.y, rect.w, clipH); ctx.clip(); - - // Draw waveform + + // Draw waveform — only for the audio portion ctx.strokeStyle = hexToRgba(rect.color, 0.7); ctx.fillStyle = hexToRgba(rect.color, 0.3); ctx.lineWidth = Math.max(1, dpr); - - // If we have more peaks than pixels, aggregate them + + const peaksPerPixel = peaks.length / audioW; + if (peaksPerPixel > 1) { - for (let x = 0; x < rect.w; x++) { + // More peaks than pixels — aggregate + for (let x = 0; x < audioW; x++) { const peakStart = Math.floor(x * peaksPerPixel); const peakEnd = Math.floor((x + 1) * peaksPerPixel); - + let min = 1.0; let max = -1.0; - - // Find min/max in this pixel's range + for (let i = peakStart; i < peakEnd && i < peaks.length; i++) { if (peaks[i][0] < min) min = peaks[i][0]; if (peaks[i][1] > max) max = peaks[i][1]; } - + const yMin = centerY - max * amplitude; const yMax = centerY - min * amplitude; - - // Draw vertical line for this pixel + ctx.beginPath(); ctx.moveTo(rect.x + x, yMin); ctx.lineTo(rect.x + x, yMax); ctx.stroke(); } } else { - // We have fewer peaks than pixels, so interpolate + // Fewer peaks than pixels — draw at correct positions without stretching ctx.beginPath(); - - // Top line (max values) + for (let i = 0; i < peaks.length; i++) { - const x = rect.x + (i / (peaks.length - 1)) * rect.w; + const x = rect.x + i; // 1 peak = 1 pixel const y = centerY - peaks[i][1] * amplitude; - - if (i === 0) { - ctx.moveTo(x, y); - } else { - ctx.lineTo(x, y); - } + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); } - - // Bottom line (min values, reversed) + for (let i = peaks.length - 1; i >= 0; i--) { - const x = rect.x + (i / (peaks.length - 1)) * rect.w; + const x = rect.x + i; const y = centerY - peaks[i][0] * amplitude; ctx.lineTo(x, y); } - + ctx.closePath(); ctx.fill(); ctx.stroke(); } - - // Draw center line + + // Draw center line across the full clip width ctx.strokeStyle = hexToRgba(rect.color, 0.2); ctx.lineWidth = dpr; ctx.beginPath(); ctx.moveTo(rect.x, centerY); ctx.lineTo(rect.x + rect.w, centerY); ctx.stroke(); - + ctx.restore(); }; @@ -548,7 +543,9 @@ 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 + // If sourceDuration is known, use it; otherwise use offset+duration as the + // buffer bound (the clip can be trimmed inward but never extended outward) + const srcDur = dragRef.current.sourceDuration || (offset + dur); const op = dragRef.current.op; let newStart = start; let newDur = dur; @@ -557,25 +554,20 @@ 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); + // Trim from left: advance in-point into the buffer + const rawStart = start + dxSec; + // Can't drag left edge past the buffer start (offset would go negative) + newStart = Math.max(start - offset, rawStart); + // Can't drag left edge past the right edge + newStart = Math.min(newStart, start + dur - MIN_DUR); const delta = newStart - start; - 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; - } + newOffset = (offset || 0) + delta; + newDur = dur - delta; } else if (op === 'resizeR') { + // Trim from right: adjust out-point + const maxDur = srcDur - (offset || 0); 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); - } + newDur = Math.min(newDur, maxDur); // can't extend past source audio } draw(); From 96fa00a431b98f1431ec01a7cefa4ad60b240021 Mon Sep 17 00:00:00 2001 From: espadonne Date: Tue, 31 Mar 2026 21:40:33 -0400 Subject: [PATCH 2/4] rewrite waveform rendering: per-pixel min/max from AudioBuffer 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 --- contexts/TrackClipCanvas.jsx | 144 +++++++++++++++++------------------ 1 file changed, 71 insertions(+), 73 deletions(-) diff --git a/contexts/TrackClipCanvas.jsx b/contexts/TrackClipCanvas.jsx index ca5b68e..91a233b 100644 --- a/contexts/TrackClipCanvas.jsx +++ b/contexts/TrackClipCanvas.jsx @@ -23,7 +23,7 @@ export default function TrackClipCanvas({ track, zoomLevel = 100, height = 100, const canvasRef = useRef(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 [bufferCache, setBufferCache] = useState(new Map()); // clip.src -> AudioBuffer const clips = Array.isArray(track?.clips) ? track.clips : []; // In select mode, all tracks are clickable (for cross-track selection) @@ -34,38 +34,33 @@ export default function TrackClipCanvas({ track, zoomLevel = 100, height = 100, const MIN_DUR = 0.02; // 20ms const HANDLE_W = 8; // CSS px - // Load peaks for all clips + // Decode and cache AudioBuffers for all clip source URLs useEffect(() => { - const loadPeaks = async () => { - const newPeaksCache = new Map(); - + let cancelled = false; + const loadBuffers = async () => { + const needed = new Set(); for (const clip of clips) { - if (!clip.src) continue; + if (clip.src && !clip.isLoading && !bufferCache.has(clip.src)) { + needed.add(clip.src); + } + } + if (needed.size === 0) return; + const newCache = new Map(bufferCache); + for (const url of needed) { try { - // Calculate pixels-per-second to match timeline - const pixelsPerSecond = zoomLevel; // 100 zoom = 100 pixels/second - const clipWidthPx = Math.max(1, (clip.duration || 0) * pixelsPerSecond); - - const peaks = await waveformCache.getPeaksForClip( - clip.src, - clip.offset || 0, - clip.duration || 0, - clipWidthPx, - zoomLevel - ); - - newPeaksCache.set(clip.id, peaks); + const result = await waveformCache.getPeaksForURL(url, 256); + if (!cancelled) newCache.set(url, result.audioBuffer); } catch (err) { - console.warn(`Failed to load peaks for clip ${clip.id}:`, err); + console.warn(`Failed to decode audio for waveform: ${url}`, err); } } - - setPeaksCache(newPeaksCache); + if (!cancelled) setBufferCache(newCache); }; - - loadPeaks(); - }, [clips, duration, zoomLevel]); + + loadBuffers(); + return () => { cancelled = true; }; + }, [clips]); const resizeToCSS = (canvas) => { const dpr = window.devicePixelRatio || 1; @@ -143,84 +138,87 @@ export default function TrackClipCanvas({ track, zoomLevel = 100, height = 100, ctx.restore(); }; - // Draw waveform for a clip + // Draw waveform for a clip — computes per-pixel min/max directly from + // the decoded AudioBuffer, following the approach used by Audacity and + // wavesurfer.js. The number of drawn columns always equals the clip's + // pixel width so the waveform never stretches or drops regions. const drawWaveform = (ctx, clip, rect, dpr) => { - // If clip is loading, show loading state instead if (clip.isLoading) { drawLoadingState(ctx, clip, rect, dpr); return; } - const peaks = peaksCache.get(clip.id); - if (!peaks || peaks.length === 0) return; + const audioBuffer = bufferCache.get(clip.src); + if (!audioBuffer) return; + + const channelData = audioBuffer.getChannelData(0); + const sampleRate = audioBuffer.sampleRate; + const totalSamples = channelData.length; const clipH = rect.h; const centerY = rect.y + clipH / 2; - const amplitude = (clipH - 12 * dpr) / 2; // Leave some padding + const amplitude = (clipH - 12 * dpr) / 2; - // Determine actual audio width — peaks represent the real audio content. - // Map 1 peak = 1 pixel at current zoom so waveform never stretches. - const audioW = Math.min(rect.w, peaks.length); + // Pixel width of this clip in canvas pixels (already DPR-scaled in rect.w) + const pixelW = rect.w; + if (pixelW < 1) return; - ctx.save(); + // Sample range within the buffer for this clip's visible window + const offsetSamples = Math.floor((clip.offset || 0) * sampleRate); + const durationSamples = Math.floor((clip.duration || 0) * sampleRate); + const endSample = Math.min(offsetSamples + durationSamples, totalSamples); + + // How many samples map to one pixel column (Audacity's samplesPerColumn) + const samplesPerPixel = Math.max(1, durationSamples / pixelW); - // Set up clipping region to contain waveform within clip bounds + ctx.save(); ctx.beginPath(); ctx.rect(rect.x, rect.y, rect.w, clipH); ctx.clip(); - // Draw waveform — only for the audio portion ctx.strokeStyle = hexToRgba(rect.color, 0.7); - ctx.fillStyle = hexToRgba(rect.color, 0.3); ctx.lineWidth = Math.max(1, dpr); - const peaksPerPixel = peaks.length / audioW; + let prevMin = 0, prevMax = 0; - if (peaksPerPixel > 1) { - // More peaks than pixels — aggregate - for (let x = 0; x < audioW; x++) { - const peakStart = Math.floor(x * peaksPerPixel); - const peakEnd = Math.floor((x + 1) * peaksPerPixel); + for (let col = 0; col < pixelW; col++) { + // Use Audacity's rounding pattern to avoid cumulative drift + const sampleStart = offsetSamples + Math.round(col * samplesPerPixel); + const sampleEnd = Math.min( + offsetSamples + Math.round((col + 1) * samplesPerPixel), + endSample + ); - let min = 1.0; - let max = -1.0; - - for (let i = peakStart; i < peakEnd && i < peaks.length; i++) { - if (peaks[i][0] < min) min = peaks[i][0]; - if (peaks[i][1] > max) max = peaks[i][1]; - } + if (sampleStart >= endSample) break; // past the audio - const yMin = centerY - max * amplitude; - const yMax = centerY - min * amplitude; + let min = 1.0; + let max = -1.0; - ctx.beginPath(); - ctx.moveTo(rect.x + x, yMin); - ctx.lineTo(rect.x + x, yMax); - ctx.stroke(); + for (let s = sampleStart; s < sampleEnd; s++) { + const v = channelData[s]; + if (v < min) min = v; + if (v > max) max = v; } - } else { - // Fewer peaks than pixels — draw at correct positions without stretching - ctx.beginPath(); - for (let i = 0; i < peaks.length; i++) { - const x = rect.x + i; // 1 peak = 1 pixel - const y = centerY - peaks[i][1] * amplitude; - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); + // Fill gaps between adjacent columns (Audacity gap-filling) + if (col > 0) { + if (prevMin > max) max = prevMin; + if (prevMax < min) min = prevMax; } - for (let i = peaks.length - 1; i >= 0; i--) { - const x = rect.x + i; - const y = centerY - peaks[i][0] * amplitude; - ctx.lineTo(x, y); - } + prevMin = min; + prevMax = max; - ctx.closePath(); - ctx.fill(); + const yMin = centerY - max * amplitude; + const yMax = centerY - min * amplitude; + + ctx.beginPath(); + ctx.moveTo(rect.x + col, yMin); + ctx.lineTo(rect.x + col, yMax); ctx.stroke(); } - // Draw center line across the full clip width + // Center line ctx.strokeStyle = hexToRgba(rect.color, 0.2); ctx.lineWidth = dpr; ctx.beginPath(); @@ -641,7 +639,7 @@ export default function TrackClipCanvas({ track, zoomLevel = 100, height = 100, }; }, [clipRects, currentTime, duration, zoomLevel, interactive, selectedClipId, selectedClipIds, selectedTrackId, snapEnabled, gridSizeSec, setSelectedTrackId, setSelectedClipId, - setSelectedClipIds, setTracks, track?.id, peaksCache, clips, editorTool, logOperation]); + setSelectedClipIds, setTracks, track?.id, bufferCache, clips, editorTool, logOperation]); return ( Date: Tue, 31 Mar 2026 21:45:33 -0400 Subject: [PATCH 3/4] fix right-edge restore: resolve sourceDuration from buffer cache on drag start --- contexts/TrackClipCanvas.jsx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/contexts/TrackClipCanvas.jsx b/contexts/TrackClipCanvas.jsx index 91a233b..4aa1324 100644 --- a/contexts/TrackClipCanvas.jsx +++ b/contexts/TrackClipCanvas.jsx @@ -448,7 +448,10 @@ 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; + // Resolve sourceDuration: clip field → buffer cache → null + dragRef.current.sourceDuration = c.sourceDuration + || bufferCache.get(c.src)?.duration + || null; // Stop propagation so SelectionOverlay doesn't interfere e.stopPropagation(); @@ -508,7 +511,9 @@ 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; + dragRef.current.sourceDuration = c.sourceDuration + || bufferCache.get(c.src)?.duration + || null; } else { dragRef.current.op = null; dragRef.current.clipIndex = -1; @@ -607,9 +612,18 @@ export default function TrackClipCanvas({ track, zoomLevel = 100, height = 100, dragRef.current.clipIndex = -1; dragRef.current.preview = null; if (!p) { draw(); return; } + const resolvedSrcDur = dragRef.current.sourceDuration; setTracks((prev) => prev.map((t) => { if (t.id !== track.id || !Array.isArray(t.clips)) return t; - const nextClips = t.clips.map((c, i) => i === idx ? { ...c, start: p.start, duration: p.duration, offset: p.offset } : c); + const nextClips = t.clips.map((c, i) => { + if (i !== idx) return c; + const updated = { ...c, start: p.start, duration: p.duration, offset: p.offset }; + // Backfill sourceDuration if the clip didn't have it + if (!c.sourceDuration && resolvedSrcDur) { + updated.sourceDuration = resolvedSrcDur; + } + return updated; + }); return { ...t, clips: nextClips }; })); From 65ce7c5ad71a9a5ca97bd59e34f57fbfba119e15 Mon Sep 17 00:00:00 2001 From: espadonne Date: Tue, 31 Mar 2026 22:10:17 -0400 Subject: [PATCH 4/4] proxy /media/ through Next.js rewrite to avoid CORS on audio fetch --- next.config.js | 4 ++++ .../[piece]/[actCategory]/[partType]/activity/[step].js | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/next.config.js b/next.config.js index d02192c..551d099 100644 --- a/next.config.js +++ b/next.config.js @@ -5,6 +5,10 @@ module.exports = { source: '/backend/:rest*', destination: `${process.env.NEXT_PUBLIC_BACKEND_HOST}/:rest*`, }, + { + source: '/media/:rest*', + destination: `${process.env.NEXT_PUBLIC_BACKEND_HOST}/media/:rest*`, + }, { source: '/ffmpeg-core/:path*', destination: 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/:path*', diff --git a/pages/courses/[slug]/[piece]/[actCategory]/[partType]/activity/[step].js b/pages/courses/[slug]/[piece]/[actCategory]/[partType]/activity/[step].js index 70f3fbb..c911ddd 100644 --- a/pages/courses/[slug]/[piece]/[actCategory]/[partType]/activity/[step].js +++ b/pages/courses/[slug]/[piece]/[actCategory]/[partType]/activity/[step].js @@ -285,7 +285,12 @@ export default function ActivityPage() { const basslineAssignment = loadedActivities ? activities[piece]?.find((a) => a.part_type === 'Bassline') : null; - const basslineURL = basslineAssignment?.part?.sample_audio || null; + // Convert absolute backend URL to relative /media/... path so it routes through + // Next.js rewrite proxy (avoids CORS issues with cross-origin fetch/decodeAudioData) + const rawBasslineURL = basslineAssignment?.part?.sample_audio || null; + const basslineURL = rawBasslineURL + ? rawBasslineURL.replace(/^https?:\/\/[^/]+/, '') // strip origin, keep /media/... + : null; const sampleTakes = (stepNumber === 3 && basslineURL) ? [{ id: 'sample-bassline', name: basslineAssignment?.part?.piece?.name