Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
230 changes: 117 additions & 113 deletions contexts/TrackClipCanvas.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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;
Expand Down Expand Up @@ -143,96 +138,94 @@ 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

// Calculate how many peaks to draw per pixel
const peaksPerPixel = peaks.length / rect.w;

const amplitude = (clipH - 12 * dpr) / 2;

// Pixel width of this clip in canvas pixels (already DPR-scaled in rect.w)
const pixelW = rect.w;
if (pixelW < 1) return;

// 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);

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

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
if (peaksPerPixel > 1) {
for (let x = 0; x < rect.w; 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
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 y = centerY - peaks[i][1] * amplitude;

if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}

let prevMin = 0, prevMax = 0;

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
);

if (sampleStart >= endSample) break; // past the audio

let min = 1.0;
let max = -1.0;

for (let s = sampleStart; s < sampleEnd; s++) {
const v = channelData[s];
if (v < min) min = v;
if (v > max) max = v;
}

// 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 y = centerY - peaks[i][0] * amplitude;
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;
}

ctx.closePath();
ctx.fill();

prevMin = min;
prevMax = max;

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

// Center line
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();
};

Expand Down Expand Up @@ -455,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();
Expand Down Expand Up @@ -515,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;
Expand Down Expand Up @@ -548,7 +546,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;
Expand All @@ -557,25 +557,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();
Expand Down Expand Up @@ -617,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 };
}));

Expand Down Expand Up @@ -649,7 +653,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 (
<canvas
Expand Down
4 changes: 4 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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*',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading