Skip to content

Commit 9b98738

Browse files
authored
Merge pull request #155 from espadonne/fix/activity3-bassline-source
Fix Activity 3 bassline sourced from wrong part
2 parents e054435 + 9f4c220 commit 9b98738

11 files changed

Lines changed: 129 additions & 81 deletions

File tree

api.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@
22
import { getSession } from 'next-auth/react';
33

44
// https://allover.twodee.org/remote-state/fetching-memories/
5-
function assertResponse(response) {
5+
async function assertResponse(response) {
66
if (response.status >= 200 && response.status < 300) {
77
return response;
88
}
9-
throw new Error(`${response.status}: ${response.statusText}`);
9+
// Include response body in error for debugging
10+
let detail = '';
11+
try {
12+
const body = await response.clone().text();
13+
detail = ` — ${body}`;
14+
} catch (_) { /* ignore */ }
15+
throw new Error(`${response.status}: ${response.statusText}${detail}`);
1016
}
1117

1218
async function getDjangoToken() {
@@ -39,7 +45,7 @@ async function makeRequest(
3945
body: body ? JSON.stringify(body) : null,
4046
});
4147

42-
assertResponse(response);
48+
await assertResponse(response);
4349

4450
const data = await response.json();
4551
return data;

components/audio/DAW/CustomWaveform/CustomTimeline.jsx

Lines changed: 38 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -76,19 +76,24 @@ export default function CustomTimeline() {
7676

7777
// Apply the spliced audio
7878
await applyProcessedAudio(splicedBuffer);
79-
80-
// Log for study protocol (retain/scissor operation)
81-
if (logOperation) {
82-
logOperation('clip_cut', { start: activeRegion.start, end: activeRegion.end });
83-
logOperation('region_retained', { start: activeRegion.start, end: activeRegion.end });
84-
}
85-
8679
} catch (error) {
8780
console.error('Error splicing region:', error);
8881
alert('Failed to splice region');
89-
} finally {
9082
setIsProcessing(false);
83+
return;
84+
}
85+
86+
// Log for study protocol — separated so logging failures don't block UI
87+
if (logOperation) {
88+
try {
89+
await logOperation('clip_cut', { start: activeRegion.start, end: activeRegion.end });
90+
await logOperation('region_retained', { start: activeRegion.start, end: activeRegion.end });
91+
} catch (error) {
92+
console.error('⚠️ Failed to log operation (audio edit succeeded):', error);
93+
}
9194
}
95+
96+
setIsProcessing(false);
9297
}, [activeRegion, audioBuffer, applyProcessedAudio, audioContext, isProcessing, logOperation]);
9398

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

104-
try {
105-
// Check if deletion is at beginning or end (silence trimming)
106-
const duration = audioBuffer.duration;
107-
const isStartTrim = activeRegion.start < 0.5; // Within 0.5s of start
108-
const isEndTrim = activeRegion.end > (duration - 0.5); // Within 0.5s of end
109+
// Check if deletion is at beginning or end (silence trimming)
110+
const duration = audioBuffer.duration;
111+
const isStartTrim = activeRegion.start < 0.5; // Within 0.5s of start
112+
const isEndTrim = activeRegion.end > (duration - 0.5); // Within 0.5s of end
109113

114+
try {
110115
// Remove the selected region
111116
const cutBuffer = cutRegionFromBuffer(
112117
audioBuffer,
@@ -117,31 +122,30 @@ export default function CustomTimeline() {
117122

118123
// Apply the cut audio
119124
await applyProcessedAudio(cutBuffer);
125+
} catch (error) {
126+
console.error('Error cutting region:', error);
127+
alert('Failed to cut region');
128+
setIsProcessing(false);
129+
return;
130+
}
120131

121-
// Log for study protocol (delete operation)
122-
if (logOperation) {
123-
console.log('🎯 Logging clip_delete operation:', { start: activeRegion.start, end: activeRegion.end });
124-
logOperation('clip_delete', { start: activeRegion.start, end: activeRegion.end });
125-
126-
// Also log silence trimming if applicable
132+
// Log for study protocol — separated from audio processing so logging
133+
// failures don't show "Failed to cut region" or block the UI
134+
if (logOperation) {
135+
try {
136+
await logOperation('clip_delete', { start: activeRegion.start, end: activeRegion.end });
127137
if (isStartTrim) {
128-
console.log('🎯 Logging silence_trimmed_start operation');
129-
logOperation('silence_trimmed_start', { region: activeRegion });
138+
await logOperation('silence_trimmed_start', { region: activeRegion });
130139
}
131140
if (isEndTrim) {
132-
console.log('🎯 Logging silence_trimmed_end operation');
133-
logOperation('silence_trimmed_end', { region: activeRegion });
141+
await logOperation('silence_trimmed_end', { region: activeRegion });
134142
}
135-
} else {
136-
console.warn('⚠️ logOperation is not available for clip_delete');
143+
} catch (error) {
144+
console.error('⚠️ Failed to log operation (audio edit succeeded):', error);
137145
}
138-
139-
} catch (error) {
140-
console.error('Error cutting region:', error);
141-
alert('Failed to cut region');
142-
} finally {
143-
setIsProcessing(false);
144146
}
147+
148+
setIsProcessing(false);
145149
}, [activeRegion, audioBuffer, applyProcessedAudio, audioContext, isProcessing, logOperation]);
146150

147151
return (
@@ -195,15 +199,15 @@ export default function CustomTimeline() {
195199
<div className="d-flex align-items-center">
196200
<Button
197201
className="prog-button pr-2"
198-
onClick={() => {
202+
onClick={async () => {
199203
undo();
200204
// Log for study protocol
201205
if (logOperation) {
202-
logOperation('undo_action', {});
206+
await logOperation('undo_action', {});
203207

204208
// Check if we've restored to original state (Activity 2 requirement)
205209
if (isRestoredToOriginal && isRestoredToOriginal()) {
206-
logOperation('audio_restored', {
210+
await logOperation('audio_restored', {
207211
message: 'Audio restored to original state via undo'
208212
});
209213
}

components/audio/DAW/Multitrack/AudioEngine.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ export function createTransport({ onTick, getProjectDurationSec }) {
9393
},
9494
play(fromSec = null) {
9595
if (fromSec != null) state.startAtSec = Math.max(0, fromSec);
96+
// Resume suspended AudioContext (browsers block auto-created contexts)
97+
if (audioContext.state === 'suspended') {
98+
audioContext.resume();
99+
}
96100
state.contextStartTime = audioContext.currentTime;
97101
state.isPlaying = true;
98102
cancelAnimationFrame(state.rafId);

components/audio/DAW/Multitrack/MultitrackWithTakes.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@ import MultitrackEditor from './MultitrackEditor';
88
* Wrapper component that connects the RecordingContext takes
99
* to the MultitrackEditor
1010
*/
11-
export default function MultitrackWithTakes({ logOperation = null }) {
11+
export default function MultitrackWithTakes({ logOperation = null, sampleTakes = [] }) {
1212
const { blobInfo } = useRecording();
1313

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

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

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

31-
return <MultitrackEditor availableTakes={transformedTakes} logOperation={logOperation} />;
32+
console.log('🎵 MultitrackWithTakes: Transformed takes:', allTakes);
33+
34+
return <MultitrackEditor availableTakes={allTakes} logOperation={logOperation} />;
3235
}

components/audio/DAW/Multitrack/RecordingTrack.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ export default function RecordingTrack({ track, index, zoomLevel = 100 }) {
253253
id: `clip-${track.id}-${Date.now()}`,
254254
start: recordingStartPosition, // Use the stored start position
255255
duration: audioDuration,
256+
sourceDuration: audioDuration, // total buffer length for trim clamping
256257
src: url,
257258
offset: 0,
258259
color: track.color || '#ff6b6b',

components/audio/DAW/Multitrack/TakesImportModal.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,24 @@ export default function TakesImportModal({ show, onHide, takes = [] }) {
1111
const { addTrack, updateTrack } = useMultitrack();
1212
const [selectedTake, setSelectedTake] = useState(null);
1313
const [trackName, setTrackName] = useState('');
14+
const [resolvedDurations, setResolvedDurations] = useState({});
15+
16+
// Resolve durations for takes that report 0 when the modal opens
17+
useEffect(() => {
18+
if (!show || takes.length === 0) return;
19+
20+
takes.forEach((take) => {
21+
if (take.duration > 0 || !take.audioURL || resolvedDurations[take.id] != null) return;
22+
const audio = new Audio();
23+
audio.preload = 'metadata';
24+
audio.onloadedmetadata = () => {
25+
if (audio.duration && isFinite(audio.duration)) {
26+
setResolvedDurations((prev) => ({ ...prev, [take.id]: audio.duration }));
27+
}
28+
};
29+
audio.src = take.audioURL;
30+
});
31+
}, [show, takes]);
1432

1533
// Update track name when take is selected
1634
useEffect(() => {
@@ -109,6 +127,7 @@ export default function TakesImportModal({ show, onHide, takes = [] }) {
109127
const finalClip = {
110128
...placeholderClip,
111129
duration: result.duration,
130+
sourceDuration: result.duration, // total buffer length for trim clamping
112131
isLoading: false,
113132
loadingState: 'complete',
114133
processingMethod: result.method
@@ -141,6 +160,7 @@ export default function TakesImportModal({ show, onHide, takes = [] }) {
141160
const finalClip = {
142161
...placeholderClip,
143162
duration: duration,
163+
sourceDuration: duration, // total buffer length for trim clamping
144164
isLoading: false,
145165
loadingState: 'complete',
146166
processingMethod: 'fallback'
@@ -231,7 +251,7 @@ export default function TakesImportModal({ show, onHide, takes = [] }) {
231251
</div>
232252
<small className="text-muted">
233253
<FaClock className="me-1" />
234-
{formatDuration(take.duration)}{' '}
254+
{formatDuration(resolvedDurations[take.id] ?? take.duration)}{' '}
235255
{formatDate(take.createdAt)}
236256
</small>
237257
</div>

components/audio/DAW/Multitrack/Track.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,7 @@ export default function Track({ track, index, zoomLevel = 100 }) {
430430
id: `clip-${track.id}-${Date.now()}`,
431431
start: recordingStartPosition, // Use the stored start position
432432
duration: audioDuration,
433+
sourceDuration: audioDuration, // total buffer length for trim clamping
433434
src: url,
434435
offset: 0,
435436
color: track.color || '#ff6b6b',

components/audio/DAW/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export default function DAW({
3737
showSubmitButton = false,
3838
silenceWarning = false,
3939
logOperation = null, // For study protocol tracking
40+
sampleTakes = [], // Pre-loaded takes (e.g. bassline) for Import Takes modal
4041
}) {
4142
const { audioURL, dawMode, setDawMode, activityLogger } = useAudio();
4243
const { loadFFmpeg, loaded: ffmpegLoaded } = useFFmpeg();
@@ -125,7 +126,7 @@ export default function DAW({
125126
</CardHeader>
126127

127128
<CardBody style={{ backgroundColor: '#2d2c29' }}>
128-
<MultitrackWithTakes logOperation={logOperation} />
129+
<MultitrackWithTakes logOperation={logOperation} sampleTakes={sampleTakes} />
129130
</CardBody>
130131

131132
{showSubmitButton && (

contexts/MultitrackContext.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export const MultitrackProvider = ({ children, initialTracks = [] }) => {
175175
id: `clip-${track.id}`,
176176
start: 0,
177177
duration: d,
178+
sourceDuration: d, // total buffer length for trim clamping
178179
color: track.color || '#7bafd4',
179180
src: track.audioURL,
180181
offset: 0,

contexts/TrackClipCanvas.jsx

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export default function TrackClipCanvas({ track, zoomLevel = 100, height = 100,
2121
} = useMultitrack();
2222

2323
const canvasRef = useRef(null);
24-
const dragRef = useRef({ op: null, clipIndex: -1, startX: 0, pxPerSecCSS: 1, orig: null });
24+
const dragRef = useRef({ op: null, clipIndex: -1, startX: 0, pxPerSecCSS: 1, orig: null, sourceDuration: null });
2525
// selectionBoxRef removed - selection box now handled by SelectionOverlay component
2626
const [peaksCache, setPeaksCache] = useState(new Map()); // clip.id -> peaks
2727
const clips = Array.isArray(track?.clips) ? track.clips : [];
@@ -429,15 +429,12 @@ export default function TrackClipCanvas({ track, zoomLevel = 100, height = 100,
429429

430430
// Handle select tool
431431
if (editorTool === 'select') {
432-
console.log('🔶 TrackClipCanvas: Select tool click', { trackId: track.id, hitIndex: hit.index, clipCount: clips.length });
433432
if (hit.index >= 0) {
434433
// Clicked on a clip - handle selection
435434
const c = clips[hit.index];
436435
const isShift = e.shiftKey;
437436
const isCtrl = e.ctrlKey || e.metaKey;
438437

439-
console.log('🔶 TrackClipCanvas: Clicked on clip', { clipId: c.id, isShift, isCtrl });
440-
441438
if (isShift || isCtrl) {
442439
// Add to or toggle from selection
443440
if (selectedClipIds.includes(c.id)) {
@@ -449,18 +446,22 @@ export default function TrackClipCanvas({ track, zoomLevel = 100, height = 100,
449446
// Single select (replace selection)
450447
setSelectedClipId(c.id);
451448
setSelectedClipIds([c.id]);
452-
// Set track as selected when doing single selection
453449
setSelectedTrackId(track.id);
454450
}
455451

452+
// Initialize drag so the clip can be moved in one click-drag motion
453+
const op = hit.edge === 'L' ? 'resizeL' : hit.edge === 'R' ? 'resizeR' : 'move';
454+
dragRef.current.op = op;
455+
dragRef.current.clipIndex = hit.index;
456+
dragRef.current.startX = e.clientX;
457+
dragRef.current.orig = { start: c.start || 0, duration: c.duration || 0, offset: c.offset || 0 };
458+
dragRef.current.sourceDuration = c.sourceDuration || null;
459+
456460
// Stop propagation so SelectionOverlay doesn't interfere
457461
e.stopPropagation();
458462
return;
459-
} else {
460-
console.log('🔶 TrackClipCanvas: Clicked on empty space, letting SelectionOverlay handle it');
461463
}
462-
// Note: Selection box dragging is now handled by SelectionOverlay component
463-
// which operates at the container level for cross-track selection
464+
// Empty space click — let SelectionOverlay handle it
464465
return;
465466
}
466467

@@ -514,6 +515,7 @@ export default function TrackClipCanvas({ track, zoomLevel = 100, height = 100,
514515
dragRef.current.clipIndex = hit.index;
515516
dragRef.current.startX = e.clientX;
516517
dragRef.current.orig = { start: c.start || 0, duration: c.duration || 0, offset: c.offset || 0 };
518+
dragRef.current.sourceDuration = c.sourceDuration || null;
517519
} else {
518520
dragRef.current.op = null;
519521
dragRef.current.clipIndex = -1;
@@ -546,6 +548,7 @@ export default function TrackClipCanvas({ track, zoomLevel = 100, height = 100,
546548
const dxSecRaw = dxCss / dragRef.current.pxPerSecCSS;
547549
const dxSec = snapEnabled ? quantize(dxSecRaw) : dxSecRaw;
548550
const { start, duration: dur, offset } = dragRef.current.orig;
551+
const srcDur = dragRef.current.sourceDuration; // total audio buffer length
549552
const op = dragRef.current.op;
550553
let newStart = start;
551554
let newDur = dur;
@@ -554,12 +557,25 @@ export default function TrackClipCanvas({ track, zoomLevel = 100, height = 100,
554557
if (op === 'move') {
555558
newStart = Math.max(0, start + dxSec);
556559
} else if (op === 'resizeL') {
560+
// Trim from left: advance offset into the buffer, can't go past offset 0
557561
newStart = Math.max(0, start + dxSec);
558562
const delta = newStart - start;
559-
newDur = Math.max(MIN_DUR, dur - delta);
560563
newOffset = Math.max(0, (offset || 0) + delta);
564+
newDur = Math.max(MIN_DUR, dur - delta);
565+
// Clamp: can't reveal audio before the buffer start
566+
if (newOffset < 0) {
567+
const correction = -newOffset;
568+
newOffset = 0;
569+
newStart += correction;
570+
newDur -= correction;
571+
}
561572
} else if (op === 'resizeR') {
562573
newDur = Math.max(MIN_DUR, dur + dxSec);
574+
// Clamp: can't extend past the end of the source audio buffer
575+
if (srcDur != null) {
576+
const maxDur = srcDur - (newOffset || offset || 0);
577+
newDur = Math.min(newDur, maxDur);
578+
}
563579
}
564580

565581
draw();

0 commit comments

Comments
 (0)