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
2 changes: 2 additions & 0 deletions agent_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,12 @@
* **Idea:** "AI Auto-Mix Assistant" - Automatically adjusts levels, panning, and EQ based on track content to maintain a balanced mix.
* **Idea:** "Real-time Convolution Reverb for Vocal Spaces" - Enhance the dynamic reverb by allowing users to select impulse response types.
* **Idea:** "Per-Step Breath Intensity" - Allow sequence steps to override global breathiness for rhythmic breathing and whisper effects. (Implemented!)
* **Idea:** "Auto-Slice by Transients" - Use energy-based analysis to automatically detect and place slice markers at drum hits or clear transients when a custom sample is loaded.

---

## 📜 Changelog
* [2026-06-21] - Implemented Custom Sample Slicing UI: Updated `WaveformDisplay.tsx` to handle drag-to-adjust, double-click-to-split, and double-click-to-merge interactions on slice boundaries. Hooked it up to `SamplerPanel` and `useAudioEngine` via a new `setAlignment` override function. Added "Auto-Slice by Transients" idea to the Innovation Lab.
* [2026-06-21] - Implemented Custom Sample Slicing UI: Enhanced `WaveformDisplay.tsx` to support interactive mousedown, drag, and double-click events, allowing users to manually slice custom WAV files directly on the canvas. Connected to the AudioEngine via `setAlignment`.
* [2026-06-20] - Implemented Glissando/Portamento Curves & Per-Step Breath Intensity: Added `slideType` parameter (Linear/Exponential) to allow musical variations of pitch glides in TTS, and allowed individual steps to override global breath noise via `breathIntensity`. Added Custom Sample Slicing UI to Active Backlog.
* [2026-06-19] - Implemented Global Saturation: Added a master channel `WaveShaperNode` with a variable distortion curve mapped to a "Warmth" (Saturation) slider in the top utility UI. Routed the entire master mix through it to add glue and presence. Added new idea: "AI Auto-Mix Assistant".
Expand Down
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1100,7 +1100,7 @@ export const App: React.FC = () => {
</div>
</div>
), [bass2.waveform, updateBass2]);
const samplerChild = useMemo(() => (<div className="absolute top-2 left-[25%] w-[50%] max-h-[280px] h-auto pointer-events-auto z-10 bg-gray-900/90 rounded-lg border border-purple-500/30 backdrop-blur-sm overflow-hidden"><SamplerPanel params={sampler} onChange={(u) => updateSampler(u)} onParamChange={handleSamplerParamChange} onLoadSample={handleLoadSample} audioContext={audioEngine?.context!} audioEngine={audioEngine || undefined} activeBankIdx={activeSamplerBank} onBankChange={setActiveSamplerBank} onOpenEditor={() => setIsVoiceEditorOpen(true)} ttsPhrases={ttsPhrases} onTtsPhraseChange={handleTtsPhraseChange} onGenerateTTS={handleGenerateTTS} loadedBanks={loadedBanks} sampleBuffer={sampleBuffers[activeSamplerBank]} sliceHighlightRef={sliceHighlightRef} melodicMode={melodicMode} onMelodicModeChange={setMelodicMode} multisampleReady={multisampleReady} multisampleProcessing={multisampleProcessing} /></div>), [sampler, updateSampler, handleSamplerParamChange, audioEngine, setIsVoiceEditorOpen, activeSamplerBank, handleLoadSample, ttsPhrases, handleGenerateTTS, loadedBanks, sampleBuffers, melodicMode, multisampleReady, multisampleProcessing]);
const samplerChild = useMemo(() => (<div className="absolute top-2 left-[25%] w-[50%] max-h-[280px] h-auto pointer-events-auto z-10 bg-gray-900/90 rounded-lg border border-purple-500/30 backdrop-blur-sm overflow-hidden"><SamplerPanel params={sampler} onChange={(u) => updateSampler(u)} onParamChange={handleSamplerParamChange} onLoadSample={handleLoadSample} audioContext={audioEngine?.context!} audioEngine={audioEngine || undefined} activeBankIdx={activeSamplerBank} onBankChange={setActiveSamplerBank} onOpenEditor={() => setIsVoiceEditorOpen(true)} ttsPhrases={ttsPhrases} onTtsPhraseChange={handleTtsPhraseChange} onGenerateTTS={handleGenerateTTS} loadedBanks={loadedBanks} sampleBuffer={sampleBuffers[activeSamplerBank]} sliceHighlightRef={sliceHighlightRef} melodicMode={melodicMode} onMelodicModeChange={setMelodicMode} multisampleReady={multisampleReady} multisampleProcessing={multisampleProcessing} alignment={activeAlignment} onAlignmentChange={(newAlignment) => { audioEngine?.setAlignment?.(activeSamplerBank, newAlignment); setActiveAlignment(newAlignment); }} /></div>), [sampler, updateSampler, handleSamplerParamChange, audioEngine, setIsVoiceEditorOpen, activeSamplerBank, handleLoadSample, ttsPhrases, handleGenerateTTS, loadedBanks, sampleBuffers, melodicMode, multisampleReady, multisampleProcessing, activeAlignment, setActiveAlignment]);

// --- RENDER PARTS FOR 3D ---
// Extract parts so they can be passed to either normal view or 3D view
Expand Down
10 changes: 9 additions & 1 deletion src/components/SamplerPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ interface SamplerPanelProps {
multisampleReady?: boolean[];
/** Which banks are currently processing */
multisampleProcessing?: boolean[];
// Slicing support
alignment?: import('../engines/rubberband/PhonemeAligner').AlignmentResult | null;
onAlignmentChange?: (alignment: import('../engines/rubberband/PhonemeAligner').AlignmentResult) => void;
}

// 8 Banks
Expand All @@ -47,7 +50,9 @@ const SamplerPanelComponent: React.FC<SamplerPanelProps> = ({
melodicMode = false, onMelodicModeChange,
multisampleProgress,
multisampleReady,
multisampleProcessing
multisampleProcessing,
alignment,
onAlignmentChange
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const dummyRef = useRef(null); // Fallback for sliceHighlightRef
Expand Down Expand Up @@ -990,5 +995,8 @@ export const SamplerPanel = memo(SamplerPanelComponent, (prev, next) => {
// 8. Check onGenerateTTS
if (prev.onGenerateTTS !== next.onGenerateTTS) return false;

// 9. Check alignment
if (prev.alignment !== next.alignment) return false;

return true;
});
200 changes: 197 additions & 3 deletions src/components/WaveformDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@ export const WaveformDisplay: React.FC<WaveformDisplayProps> = ({ buffer, alignm
const containerRef = useRef<HTMLDivElement>(null);
const activeSliceRef = useRef<number>(-1);

// Custom Slicing State
const [dragState, setDragState] = React.useState<{ index: number, isStart: boolean } | null>(null);
const [hoverState, setHoverState] = React.useState<{ index: number, isStart: boolean } | null>(null);
// State for drag interactions
const isDraggingRef = useRef(false);
const draggedMarkerIndexRef = useRef<number>(-1);

// Keep latest props in ref to access them inside the imperative callback without stale closures
const propsRef = useRef({ buffer, alignment });
useEffect(() => { propsRef.current = { buffer, alignment }; }, [buffer, alignment]);
const propsRef = useRef({ buffer, alignment, onAlignmentChange });
useEffect(() => { propsRef.current = { buffer, alignment, onAlignmentChange }; }, [buffer, alignment, onAlignmentChange]);

useEffect(() => {
const draw = () => {
Expand Down Expand Up @@ -129,6 +132,21 @@ export const WaveformDisplay: React.FC<WaveformDisplayProps> = ({ buffer, alignm
});
}

// Draw hover state if present (and we have an alignment to show)
if (alignment && hoverState && !dragState) {
const duration = buffer.duration;
const p = alignment.phonemes[hoverState.index];
const time = hoverState.isStart ? p.start : p.end;
const x = (time / duration) * width;

ctx.beginPath();
ctx.strokeStyle = '#22d3ee'; // cyan-400
ctx.lineWidth = 2;
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}

// Reset transform for next frame
ctx.setTransform(1, 0, 0, 1, 0, 0);
};
Expand All @@ -150,7 +168,177 @@ export const WaveformDisplay: React.FC<WaveformDisplayProps> = ({ buffer, alignm
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);

}, [buffer, alignment, sliceHighlightRef]);
}, [buffer, alignment, sliceHighlightRef, hoverState, dragState]);

// Custom Slicing Event Handlers
const getTimeFromEvent = (e: React.MouseEvent | MouseEvent): number | null => {
const canvas = canvasRef.current;
if (!canvas || !buffer) return null;

const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
return (x / rect.width) * buffer.duration;
};

const getNearestMarker = (time: number): { index: number, isStart: boolean, distance: number } | null => {
if (!alignment) return null;

let nearest: { index: number, isStart: boolean, distance: number } | null = null;

alignment.phonemes.forEach((p, idx) => {
const distStart = Math.abs(p.start - time);
const distEnd = Math.abs(p.end - time);

if (!nearest || distStart < nearest.distance) {
nearest = { index: idx, isStart: true, distance: distStart };
}
if (distEnd < nearest!.distance) {
nearest = { index: idx, isStart: false, distance: distEnd };
}
});

return nearest;
};

// Constants for interaction
const SNAP_DISTANCE_MS = 0.05; // 50ms snap radius

const handleMouseMove = (e: React.MouseEvent) => {
if (!buffer || !onAlignmentChange) return;

const time = getTimeFromEvent(e);
if (time === null) return;

if (dragState && alignment) {
// Dragging a marker
// Clamp time to bounds
const prevEnd = (dragState.isStart && dragState.index > 0) ? alignment.phonemes[dragState.index - 1].start : 0;
const nextStart = (!dragState.isStart && dragState.index < alignment.phonemes.length - 1) ? alignment.phonemes[dragState.index + 1].end : buffer.duration;

// Constrain by same slice's opposite end
const minTime = dragState.isStart ? prevEnd : alignment.phonemes[dragState.index].start + 0.01;
const maxTime = dragState.isStart ? alignment.phonemes[dragState.index].end - 0.01 : nextStart;

const newTime = Math.max(minTime, Math.min(maxTime, time));

const newAlignment = { ...alignment, phonemes: [...alignment.phonemes] };
const p = { ...newAlignment.phonemes[dragState.index] };

if (dragState.isStart) {
p.start = newTime;
// If there's a previous slice adjacent, update its end
if (dragState.index > 0 && Math.abs(alignment.phonemes[dragState.index - 1].end - alignment.phonemes[dragState.index].start) < 0.001) {
newAlignment.phonemes[dragState.index - 1] = { ...newAlignment.phonemes[dragState.index - 1], end: newTime };
}
} else {
p.end = newTime;
// If there's a next slice adjacent, update its start
if (dragState.index < alignment.phonemes.length - 1 && Math.abs(alignment.phonemes[dragState.index + 1].start - alignment.phonemes[dragState.index].end) < 0.001) {
newAlignment.phonemes[dragState.index + 1] = { ...newAlignment.phonemes[dragState.index + 1], start: newTime };
}
}

newAlignment.phonemes[dragState.index] = p;
onAlignmentChange(newAlignment);

} else {
// Hovering - Check for nearest marker
const nearest = getNearestMarker(time);
if (nearest && nearest.distance < SNAP_DISTANCE_MS) {
setHoverState({ index: nearest.index, isStart: nearest.isStart });
} else {
setHoverState(null);
}
}
};

const handleMouseDown = () => {
if (!buffer || !alignment || !onAlignmentChange) return;

if (hoverState) {
setDragState(hoverState);
}
};

const handleMouseUp = () => {
setDragState(null);
};

const handleMouseLeave = () => {
setHoverState(null);
setDragState(null);
};

const handleDoubleClick = (e: React.MouseEvent) => {
if (!buffer || !onAlignmentChange) return;

const time = getTimeFromEvent(e);
if (time === null) return;

if (!alignment) {
// Create initial slice if none exists
onAlignmentChange({
phonemes: [{
phoneme: 'SLICE 1',
start: 0,
end: buffer.duration,
isVowel: true
}],
sampleRate: buffer.sampleRate,
duration: buffer.duration,
text: ''
});
return;
}

const nearest = getNearestMarker(time);

// 1. Remove marker (Merge slices) if clicking close to one
if (nearest && nearest.distance < SNAP_DISTANCE_MS) {
const { index, isStart } = nearest;

// Cannot merge if it's the very beginning or end
if ((isStart && index === 0) || (!isStart && index === alignment.phonemes.length - 1)) return;

const newAlignment = { ...alignment, phonemes: [...alignment.phonemes] };

if (isStart) {
// Merge with previous
newAlignment.phonemes[index - 1].end = newAlignment.phonemes[index].end;
newAlignment.phonemes.splice(index, 1);
} else {
// Merge with next
newAlignment.phonemes[index].end = newAlignment.phonemes[index + 1].end;
newAlignment.phonemes.splice(index + 1, 1);
}

onAlignmentChange(newAlignment);
setHoverState(null);
return;
}

// 2. Add marker (Split slice) if clicking inside one
const clickedIndex = alignment.phonemes.findIndex(p => time >= p.start && time <= p.end);

if (clickedIndex !== -1) {
const p = alignment.phonemes[clickedIndex];
const newAlignment = { ...alignment, phonemes: [...alignment.phonemes] };

// Adjust current slice
const oldEnd = p.end;
newAlignment.phonemes[clickedIndex] = { ...p, end: time };

// Insert new slice
newAlignment.phonemes.splice(clickedIndex + 1, 0, {
phoneme: `SLICE ${newAlignment.phonemes.length + 1}`,
start: time,
end: oldEnd,
isVowel: true
});

onAlignmentChange(newAlignment);
}
};

// Handle mouse interactions for custom slicing
useEffect(() => {
Expand Down Expand Up @@ -324,6 +512,12 @@ export const WaveformDisplay: React.FC<WaveformDisplayProps> = ({ buffer, alignm
role="img"
aria-label={label}
title={label}
onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onDoubleClick={handleDoubleClick}
style={{ cursor: hoverState ? 'col-resize' : 'default' }}
>
<canvas ref={canvasRef} className="w-full h-full block" />
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useAudioEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ export const useAudioEngine = (pyodide: unknown) => {
isMultisampleReady,
prepareVocal,
getAlignment,
setAlignment,
setAlignment
} = createSampleLibraryControls({
loadedSampleBuffersRef,
multisampleBanksRef,
Expand Down
12 changes: 12 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,21 @@
}
},
build: {
rollupOptions: {
external: ['loader.mjs']
},
sourcemap: true,
outDir: 'dist',
},
server: {

Check failure on line 38 in vite.config.ts

View workflow job for this annotation

GitHub Actions / build-and-diagnose

An object literal cannot have multiple properties with the same name.
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
watch: {
ignored: ['**/emsdk/**']
}
},
worker: {
format: 'es',
plugins: () => [
Expand Down
Loading