From a56b089bb28b75323349718d324beed81eee047f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sun, 12 Oct 2025 11:47:31 +0200 Subject: [PATCH] Region processing --- src/App.svelte | 83 +++++++++- src/lib/audio/utils/AudioEdit.ts | 193 ++++++++++++++++++++++ src/lib/components/WaveformDisplay.svelte | 160 +++++++++++++++++- 3 files changed, 429 insertions(+), 7 deletions(-) create mode 100644 src/lib/audio/utils/AudioEdit.ts diff --git a/src/App.svelte b/src/App.svelte index d3d9d27..6acf357 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -10,6 +10,7 @@ import { AudioService } from "./lib/audio/services/AudioService"; import { downloadWAV } from "./lib/audio/utils/WAVEncoder"; import { loadVolume, saveVolume, loadDuration, saveDuration, loadPitchLockEnabled, savePitchLockEnabled, loadPitchLockFrequency, savePitchLockFrequency } from "./lib/utils/settings"; + import { cropAudio, cutAudio, processSelection } from "./lib/audio/utils/AudioEdit"; import { generateRandomColor } from "./lib/utils/colors"; import { getRandomProcessor } from "./lib/audio/processors/registry"; import type { AudioProcessor } from "./lib/audio/processors/AudioProcessor"; @@ -39,6 +40,8 @@ let pitchLockFrequency = $state(loadPitchLockFrequency()); let pitchLockInput = $state(formatFrequency(loadPitchLockFrequency())); let pitchLockInputValid = $state(true); + let selectionStart = $state(null); + let selectionEnd = $state(null); const showDuration = $derived(engineType !== 'sample'); const showRandomButton = $derived(engineType === 'generative'); @@ -47,6 +50,8 @@ const showMutateButton = $derived(engineType === 'generative' && !isProcessed && currentBuffer); const showPitchLock = $derived(engineType === 'generative'); const pitchLock = $derived({ enabled: pitchLockEnabled, frequency: pitchLockFrequency }); + const hasSelection = $derived(selectionStart !== null && selectionEnd !== null && currentBuffer !== null); + const showEditButtons = $derived(hasSelection); $effect(() => { audioService.setVolume(volume); @@ -89,13 +94,20 @@ onVolumeIncrease: (large) => { volume = Math.min(1, volume + (large ? 0.2 : 0.05)); }, - onEscape: () => showModal && closeModal(), + onEscape: () => { + if (hasSelection) { + clearSelection(); + } else if (showModal) { + closeModal(); + } + }, }); function generateRandom() { currentParams = engine.randomParams(pitchLock); waveformColor = generateRandomColor(); isProcessed = false; + clearSelection(); regenerateBuffer(); } @@ -154,9 +166,26 @@ async function applyProcessor(processor: AudioProcessor) { if (!currentBuffer) return; - const leftChannel = currentBuffer.getChannelData(0); - const rightChannel = currentBuffer.getChannelData(1); - const [processedLeft, processedRight] = await processor.process(leftChannel, rightChannel); + let processedLeft: Float32Array; + let processedRight: Float32Array; + + if (hasSelection) { + const start = Math.min(selectionStart!, selectionEnd!); + const end = Math.max(selectionStart!, selectionEnd!); + const sampleRate = audioService.getSampleRate(); + + [processedLeft, processedRight] = await processSelection( + currentBuffer, + start, + end, + processor, + sampleRate + ); + } else { + const leftChannel = currentBuffer.getChannelData(0); + const rightChannel = currentBuffer.getChannelData(1); + [processedLeft, processedRight] = await processor.process(leftChannel, rightChannel); + } currentBuffer = audioService.createAudioBuffer([processedLeft, processedRight]); isProcessed = true; @@ -169,6 +198,7 @@ currentBuffer = null; currentParams = null; isProcessed = false; + clearSelection(); if (engineType === 'generative') { generateRandom(); @@ -256,6 +286,44 @@ showProcessorPopup = false; } + function handleSelectionChange(start: number | null, end: number | null) { + selectionStart = start; + selectionEnd = end; + } + + function clearSelection() { + selectionStart = null; + selectionEnd = null; + } + + function cropSelection() { + if (!currentBuffer || selectionStart === null || selectionEnd === null) return; + + const start = Math.min(selectionStart, selectionEnd); + const end = Math.max(selectionStart, selectionEnd); + + const sampleRate = audioService.getSampleRate(); + const [newLeft, newRight] = cropAudio(currentBuffer, start, end, sampleRate); + + currentBuffer = audioService.createAudioBuffer([newLeft, newRight]); + clearSelection(); + audioService.play(currentBuffer); + } + + function cutSelection() { + if (!currentBuffer || selectionStart === null || selectionEnd === null) return; + + const start = Math.min(selectionStart, selectionEnd); + const end = Math.max(selectionStart, selectionEnd); + + const sampleRate = audioService.getSampleRate(); + const [newLeft, newRight] = cutAudio(currentBuffer, start, end, sampleRate); + + currentBuffer = audioService.createAudioBuffer([newLeft, newRight]); + clearSelection(); + audioService.play(currentBuffer); + } + async function closeModal() { showModal = false; await audioService.initialize(); @@ -375,6 +443,9 @@ buffer={currentBuffer} color={waveformColor} {playbackPosition} + {selectionStart} + {selectionEnd} + onselectionchange={handleSelectionChange} onclick={replaySound} /> {/if} @@ -391,6 +462,10 @@ {#if showMutateButton} {/if} + {#if showEditButtons} + + + {/if} {#if currentBuffer}
0 && beforeLength > 0 && afterLength > 0) { + const crossfadeStart = beforeLength - crossfadeLength; + + for (let i = 0; i < crossfadeLength; i++) { + const t = i / crossfadeLength; + const fadeOutGain = Math.cos(t * Math.PI * 0.5); + const fadeInGain = Math.sin(t * Math.PI * 0.5); + + const idx = crossfadeStart + i; + const beforeSample = newLeft[idx]; + const afterSample = newLeft[beforeLength + i]; + newLeft[idx] = beforeSample * fadeOutGain + afterSample * fadeInGain; + + const beforeSampleR = newRight[idx]; + const afterSampleR = newRight[beforeLength + i]; + newRight[idx] = beforeSampleR * fadeOutGain + afterSampleR * fadeInGain; + } + + for (let i = crossfadeLength; i < afterLength; i++) { + newLeft[beforeLength - crossfadeLength + i] = newLeft[beforeLength + i]; + newRight[beforeLength - crossfadeLength + i] = newRight[beforeLength + i]; + } + + const finalLength = beforeLength - crossfadeLength + afterLength; + return [ + newLeft.slice(0, finalLength), + newRight.slice(0, finalLength) + ]; + } + + return [newLeft, newRight]; +} + +/** + * Process only a selected region of the audio with crossfades at boundaries + */ +export async function processSelection( + buffer: AudioBuffer, + startSample: number, + endSample: number, + processor: AudioProcessor, + sampleRate: number +): Promise<[Float32Array, Float32Array]> { + const start = Math.max(0, Math.floor(startSample)); + const end = Math.min(buffer.length, Math.ceil(endSample)); + const selectionLength = end - start; + + const leftChannel = buffer.getChannelData(0); + const rightChannel = buffer.getChannelData(1); + + const selectedLeft = new Float32Array(selectionLength); + const selectedRight = new Float32Array(selectionLength); + + for (let i = 0; i < selectionLength; i++) { + selectedLeft[i] = leftChannel[start + i]; + selectedRight[i] = rightChannel[start + i]; + } + + const [processedLeft, processedRight] = await processor.process(selectedLeft, selectedRight); + + const newLeft = new Float32Array(buffer.length); + const newRight = new Float32Array(buffer.length); + + for (let i = 0; i < buffer.length; i++) { + newLeft[i] = leftChannel[i]; + newRight[i] = rightChannel[i]; + } + + for (let i = 0; i < processedLeft.length; i++) { + newLeft[start + i] = processedLeft[i]; + newRight[start + i] = processedRight[i]; + } + + const maxCrossfade = Math.floor(selectionLength / 4); + const crossfadeLength = Math.min(CROSSFADE_SAMPLES, start, buffer.length - end, maxCrossfade); + + if (crossfadeLength > 0) { + for (let i = 0; i < crossfadeLength; i++) { + const t = i / crossfadeLength; + const unprocessedGain = Math.cos(t * Math.PI * 0.5); + const processedGain = Math.sin(t * Math.PI * 0.5); + + const startIdx = start + i; + newLeft[startIdx] = leftChannel[startIdx] * unprocessedGain + processedLeft[i] * processedGain; + newRight[startIdx] = rightChannel[startIdx] * unprocessedGain + processedRight[i] * processedGain; + } + + for (let i = 0; i < crossfadeLength; i++) { + const t = i / crossfadeLength; + const processedGain = Math.cos(t * Math.PI * 0.5); + const unprocessedGain = Math.sin(t * Math.PI * 0.5); + + const endIdx = end - crossfadeLength + i; + const processedIdx = processedLeft.length - crossfadeLength + i; + newLeft[endIdx] = processedLeft[processedIdx] * processedGain + leftChannel[endIdx] * unprocessedGain; + newRight[endIdx] = processedRight[processedIdx] * processedGain + rightChannel[endIdx] * unprocessedGain; + } + } + + return [newLeft, newRight]; +} diff --git a/src/lib/components/WaveformDisplay.svelte b/src/lib/components/WaveformDisplay.svelte index 6efdbbc..c763423 100644 --- a/src/lib/components/WaveformDisplay.svelte +++ b/src/lib/components/WaveformDisplay.svelte @@ -5,11 +5,26 @@ buffer: AudioBuffer | null; color?: string; playbackPosition?: number; + selectionStart?: number | null; + selectionEnd?: number | null; + onselectionchange?: (start: number | null, end: number | null) => void; onclick?: () => void; } - let { buffer, color = '#646cff', playbackPosition = 0, onclick }: Props = $props(); + let { + buffer, + color = '#646cff', + playbackPosition = 0, + selectionStart = null, + selectionEnd = null, + onselectionchange, + onclick + }: Props = $props(); + let canvas: HTMLCanvasElement; + let isDragging = $state(false); + let dragStartX = $state(0); + let hasMoved = $state(false); onMount(() => { const resizeObserver = new ResizeObserver(() => { @@ -28,6 +43,8 @@ buffer; color; playbackPosition; + selectionStart; + selectionEnd; draw(); }); @@ -37,12 +54,120 @@ canvas.height = parent.clientHeight; } - function handleClick() { + function handleClick(event: MouseEvent) { + if (hasMoved) return; + + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + + if (buffer && selectionStart !== null && selectionEnd !== null) { + const selStart = Math.min(selectionStart, selectionEnd); + const selEnd = Math.max(selectionStart, selectionEnd); + const width = canvas.width; + const startX = (selStart / buffer.length) * width; + const endX = (selEnd / buffer.length) * width; + + if (x < startX || x > endX) { + if (onselectionchange) { + onselectionchange(null, null); + } + return; + } + } + if (onclick) { onclick(); } } + function handleMouseDown(event: MouseEvent) { + if (!buffer) return; + + isDragging = true; + hasMoved = false; + const rect = canvas.getBoundingClientRect(); + dragStartX = event.clientX - rect.left; + + const sample = Math.floor((dragStartX / canvas.width) * buffer.length); + if (onselectionchange) { + onselectionchange(sample, sample); + } + } + + function handleMouseMove(event: MouseEvent) { + if (!isDragging || !buffer) return; + + const rect = canvas.getBoundingClientRect(); + const currentX = event.clientX - rect.left; + const clampedX = Math.max(0, Math.min(canvas.width, currentX)); + + if (Math.abs(clampedX - dragStartX) > 2) { + hasMoved = true; + } + + const endSample = Math.floor((clampedX / canvas.width) * buffer.length); + + if (onselectionchange && selectionStart !== null) { + onselectionchange(selectionStart, endSample); + } + } + + function handleMouseUp() { + if (isDragging && selectionStart !== null && selectionEnd !== null) { + if (Math.abs(selectionEnd - selectionStart) < buffer!.sampleRate * 0.01) { + if (onselectionchange) { + onselectionchange(null, null); + } + } + } + isDragging = false; + } + + function handleTouchStart(event: TouchEvent) { + if (!buffer || event.touches.length !== 1) return; + + event.preventDefault(); + isDragging = true; + hasMoved = false; + const rect = canvas.getBoundingClientRect(); + dragStartX = event.touches[0].clientX - rect.left; + + const sample = Math.floor((dragStartX / canvas.width) * buffer.length); + if (onselectionchange) { + onselectionchange(sample, sample); + } + } + + function handleTouchMove(event: TouchEvent) { + if (!isDragging || !buffer || event.touches.length !== 1) return; + + event.preventDefault(); + const rect = canvas.getBoundingClientRect(); + const currentX = event.touches[0].clientX - rect.left; + const clampedX = Math.max(0, Math.min(canvas.width, currentX)); + + if (Math.abs(clampedX - dragStartX) > 2) { + hasMoved = true; + } + + const endSample = Math.floor((clampedX / canvas.width) * buffer.length); + + if (onselectionchange && selectionStart !== null) { + onselectionchange(selectionStart, endSample); + } + } + + function handleTouchEnd() { + if (isDragging && selectionStart !== null && selectionEnd !== null) { + if (Math.abs(selectionEnd - selectionStart) < buffer!.sampleRate * 0.01) { + if (onselectionchange) { + onselectionchange(null, null); + } + } + } + isDragging = false; + } + function draw() { if (!canvas) return; @@ -102,6 +227,25 @@ } } + if (buffer && selectionStart !== null && selectionEnd !== null) { + const selStart = Math.min(selectionStart, selectionEnd); + const selEnd = Math.max(selectionStart, selectionEnd); + const startX = (selStart / buffer.length) * width; + const endX = (selEnd / buffer.length) * width; + + ctx.fillStyle = 'rgba(100, 108, 255, 0.2)'; + ctx.fillRect(startX, 0, endX - startX, height); + + ctx.strokeStyle = 'rgba(100, 108, 255, 0.8)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(startX, 0); + ctx.lineTo(startX, height); + ctx.moveTo(endX, 0); + ctx.lineTo(endX, height); + ctx.stroke(); + } + if (playbackPosition >= 0 && buffer) { const duration = buffer.length / buffer.sampleRate; const x = (playbackPosition / duration) * width; @@ -116,7 +260,17 @@ } - + + +