diff --git a/agent_plan.md b/agent_plan.md index 98fa65d..be37b68 100644 --- a/agent_plan.md +++ b/agent_plan.md @@ -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". diff --git a/src/App.tsx b/src/App.tsx index 5eb4329..9a795d1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1100,7 +1100,7 @@ export const App: React.FC = () => { ), [bass2.waveform, updateBass2]); - const samplerChild = useMemo(() => (
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} />
), [sampler, updateSampler, handleSamplerParamChange, audioEngine, setIsVoiceEditorOpen, activeSamplerBank, handleLoadSample, ttsPhrases, handleGenerateTTS, loadedBanks, sampleBuffers, melodicMode, multisampleReady, multisampleProcessing]); + const samplerChild = useMemo(() => (
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); }} />
), [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 diff --git a/src/components/SamplerPanel.tsx b/src/components/SamplerPanel.tsx index 13b045c..d0e2b81 100644 --- a/src/components/SamplerPanel.tsx +++ b/src/components/SamplerPanel.tsx @@ -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 @@ -47,7 +50,9 @@ const SamplerPanelComponent: React.FC = ({ melodicMode = false, onMelodicModeChange, multisampleProgress, multisampleReady, - multisampleProcessing + multisampleProcessing, + alignment, + onAlignmentChange }) => { const fileInputRef = useRef(null); const dummyRef = useRef(null); // Fallback for sliceHighlightRef @@ -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; }); diff --git a/src/components/WaveformDisplay.tsx b/src/components/WaveformDisplay.tsx index c0a82ca..3b7fb9d 100644 --- a/src/components/WaveformDisplay.tsx +++ b/src/components/WaveformDisplay.tsx @@ -13,13 +13,16 @@ export const WaveformDisplay: React.FC = ({ buffer, alignm const containerRef = useRef(null); const activeSliceRef = useRef(-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(-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 = () => { @@ -129,6 +132,21 @@ export const WaveformDisplay: React.FC = ({ 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); }; @@ -150,7 +168,177 @@ export const WaveformDisplay: React.FC = ({ 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(() => { @@ -324,6 +512,12 @@ export const WaveformDisplay: React.FC = ({ 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' }} > diff --git a/src/hooks/useAudioEngine.ts b/src/hooks/useAudioEngine.ts index 871d64d..749ee8e 100644 --- a/src/hooks/useAudioEngine.ts +++ b/src/hooks/useAudioEngine.ts @@ -252,7 +252,7 @@ export const useAudioEngine = (pyodide: unknown) => { isMultisampleReady, prepareVocal, getAlignment, - setAlignment, + setAlignment } = createSampleLibraryControls({ loadedSampleBuffersRef, multisampleBanksRef, diff --git a/vite.config.ts b/vite.config.ts index 7db2dd2..923285d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -29,9 +29,21 @@ export default defineConfig({ } }, build: { + rollupOptions: { + external: ['loader.mjs'] + }, sourcemap: true, outDir: 'dist', }, + server: { + headers: { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + }, + watch: { + ignored: ['**/emsdk/**'] + } + }, worker: { format: 'es', plugins: () => [