Skip to content
Merged
12 changes: 9 additions & 3 deletions api.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@
import { getSession } from 'next-auth/react';

// https://allover.twodee.org/remote-state/fetching-memories/
function assertResponse(response) {
async function assertResponse(response) {
if (response.status >= 200 && response.status < 300) {
return response;
}
throw new Error(`${response.status}: ${response.statusText}`);
// Include response body in error for debugging
let detail = '';
try {
const body = await response.clone().text();
detail = ` — ${body}`;
} catch (_) { /* ignore */ }
throw new Error(`${response.status}: ${response.statusText}${detail}`);
}

async function getDjangoToken() {
Expand Down Expand Up @@ -39,7 +45,7 @@ async function makeRequest(
body: body ? JSON.stringify(body) : null,
});

assertResponse(response);
await assertResponse(response);

const data = await response.json();
return data;
Expand Down
72 changes: 38 additions & 34 deletions components/audio/DAW/CustomWaveform/CustomTimeline.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,19 +76,24 @@ export default function CustomTimeline() {

// Apply the spliced audio
await applyProcessedAudio(splicedBuffer);

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

} catch (error) {
console.error('Error splicing region:', error);
alert('Failed to splice region');
} finally {
setIsProcessing(false);
return;
}

// Log for study protocol — separated so logging failures don't block UI
if (logOperation) {
try {
await logOperation('clip_cut', { start: activeRegion.start, end: activeRegion.end });
await logOperation('region_retained', { start: activeRegion.start, end: activeRegion.end });
} catch (error) {
console.error('⚠️ Failed to log operation (audio edit succeeded):', error);
}
}

setIsProcessing(false);
}, [activeRegion, audioBuffer, applyProcessedAudio, audioContext, isProcessing, logOperation]);

// Handle cut (delete) region - remove the selected region
Expand All @@ -101,12 +106,12 @@ export default function CustomTimeline() {
if (isProcessing) return;
setIsProcessing(true);

try {
// Check if deletion is at beginning or end (silence trimming)
const duration = audioBuffer.duration;
const isStartTrim = activeRegion.start < 0.5; // Within 0.5s of start
const isEndTrim = activeRegion.end > (duration - 0.5); // Within 0.5s of end
// Check if deletion is at beginning or end (silence trimming)
const duration = audioBuffer.duration;
const isStartTrim = activeRegion.start < 0.5; // Within 0.5s of start
const isEndTrim = activeRegion.end > (duration - 0.5); // Within 0.5s of end

try {
// Remove the selected region
const cutBuffer = cutRegionFromBuffer(
audioBuffer,
Expand All @@ -117,31 +122,30 @@ export default function CustomTimeline() {

// Apply the cut audio
await applyProcessedAudio(cutBuffer);
} catch (error) {
console.error('Error cutting region:', error);
alert('Failed to cut region');
setIsProcessing(false);
return;
}

// Log for study protocol (delete operation)
if (logOperation) {
console.log('🎯 Logging clip_delete operation:', { start: activeRegion.start, end: activeRegion.end });
logOperation('clip_delete', { start: activeRegion.start, end: activeRegion.end });

// Also log silence trimming if applicable
// Log for study protocol — separated from audio processing so logging
// failures don't show "Failed to cut region" or block the UI
if (logOperation) {
try {
await logOperation('clip_delete', { start: activeRegion.start, end: activeRegion.end });
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');
} catch (error) {
console.error('⚠️ Failed to log operation (audio edit succeeded):', error);
}

} catch (error) {
console.error('Error cutting region:', error);
alert('Failed to cut region');
} finally {
setIsProcessing(false);
}

setIsProcessing(false);
}, [activeRegion, audioBuffer, applyProcessedAudio, audioContext, isProcessing, logOperation]);

return (
Expand Down Expand Up @@ -195,15 +199,15 @@ export default function CustomTimeline() {
<div className="d-flex align-items-center">
<Button
className="prog-button pr-2"
onClick={() => {
onClick={async () => {
undo();
// Log for study protocol
if (logOperation) {
logOperation('undo_action', {});
await logOperation('undo_action', {});

// Check if we've restored to original state (Activity 2 requirement)
if (isRestoredToOriginal && isRestoredToOriginal()) {
logOperation('audio_restored', {
await logOperation('audio_restored', {
message: 'Audio restored to original state via undo'
});
}
Expand Down
4 changes: 4 additions & 0 deletions components/audio/DAW/Multitrack/AudioEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 8 additions & 5 deletions components/audio/DAW/Multitrack/MultitrackWithTakes.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@ import MultitrackEditor from './MultitrackEditor';
* Wrapper component that connects the RecordingContext takes
* to the MultitrackEditor
*/
export default function MultitrackWithTakes({ logOperation = null }) {
export default function MultitrackWithTakes({ logOperation = null, sampleTakes = [] }) {
const { blobInfo } = useRecording();

console.log('🎵 MultitrackWithTakes: Raw blobInfo:', blobInfo);

// Transform blobInfo to the format expected by TakesImportModal
const transformedTakes = blobInfo.map((take, index) => ({
const recordingTakes = blobInfo.map((take, index) => ({
id: `take-${take.take}-${index}`, // Use more unique ID to avoid React key conflicts
name: take.takeName || `Take ${take.take}`,
partType: 'recording', // You might want to get this from route params
partType: 'recording',
takeNumber: take.take,
duration: 0, // Could calculate from blob if needed
createdAt: take.timeStr,
Expand All @@ -26,7 +26,10 @@ export default function MultitrackWithTakes({ logOperation = null }) {
originalData: take.data,
}));

console.log('🎵 MultitrackWithTakes: Transformed takes:', transformedTakes);
// Combine sample takes (e.g. bassline) with recording takes
const allTakes = [...sampleTakes, ...recordingTakes];

return <MultitrackEditor availableTakes={transformedTakes} logOperation={logOperation} />;
console.log('🎵 MultitrackWithTakes: Transformed takes:', allTakes);

return <MultitrackEditor availableTakes={allTakes} logOperation={logOperation} />;
}
1 change: 1 addition & 0 deletions components/audio/DAW/Multitrack/RecordingTrack.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
22 changes: 21 additions & 1 deletion components/audio/DAW/Multitrack/TakesImportModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,24 @@ export default function TakesImportModal({ show, onHide, takes = [] }) {
const { addTrack, updateTrack } = useMultitrack();
const [selectedTake, setSelectedTake] = useState(null);
const [trackName, setTrackName] = useState('');
const [resolvedDurations, setResolvedDurations] = useState({});

// Resolve durations for takes that report 0 when the modal opens
useEffect(() => {
if (!show || takes.length === 0) return;

takes.forEach((take) => {
if (take.duration > 0 || !take.audioURL || resolvedDurations[take.id] != null) return;
const audio = new Audio();
audio.preload = 'metadata';
audio.onloadedmetadata = () => {
if (audio.duration && isFinite(audio.duration)) {
setResolvedDurations((prev) => ({ ...prev, [take.id]: audio.duration }));
}
};
audio.src = take.audioURL;
});
}, [show, takes]);

// Update track name when take is selected
useEffect(() => {
Expand Down Expand Up @@ -109,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
Expand Down Expand Up @@ -141,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'
Expand Down Expand Up @@ -231,7 +251,7 @@ export default function TakesImportModal({ show, onHide, takes = [] }) {
</div>
<small className="text-muted">
<FaClock className="me-1" />
{formatDuration(take.duration)} •{' '}
{formatDuration(resolvedDurations[take.id] ?? take.duration)} •{' '}
{formatDate(take.createdAt)}
</small>
</div>
Expand Down
1 change: 1 addition & 0 deletions components/audio/DAW/Multitrack/Track.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion components/audio/DAW/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export default function DAW({
showSubmitButton = false,
silenceWarning = false,
logOperation = null, // For study protocol tracking
sampleTakes = [], // Pre-loaded takes (e.g. bassline) for Import Takes modal
}) {
const { audioURL, dawMode, setDawMode, activityLogger } = useAudio();
const { loadFFmpeg, loaded: ffmpegLoaded } = useFFmpeg();
Expand Down Expand Up @@ -125,7 +126,7 @@ export default function DAW({
</CardHeader>

<CardBody style={{ backgroundColor: '#2d2c29' }}>
<MultitrackWithTakes logOperation={logOperation} />
<MultitrackWithTakes logOperation={logOperation} sampleTakes={sampleTakes} />
</CardBody>

{showSubmitButton && (
Expand Down
1 change: 1 addition & 0 deletions contexts/MultitrackContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 26 additions & 10 deletions contexts/TrackClipCanvas.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 : [];
Expand Down Expand Up @@ -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)) {
Expand All @@ -449,18 +446,22 @@ 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 };
dragRef.current.sourceDuration = c.sourceDuration || null;

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

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down
Loading
Loading