Region processing

This commit is contained in:
2025-10-12 11:47:31 +02:00
parent fcb784d403
commit a56b089bb2
3 changed files with 429 additions and 7 deletions

View File

@ -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<number | null>(null);
let selectionEnd = $state<number | null>(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<PitchLock>({ 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}
<button onclick={mutate}>Mutate (M)</button>
{/if}
{#if showEditButtons}
<button onclick={cropSelection}>Crop</button>
<button onclick={cutSelection}>Cut</button>
{/if}
{#if currentBuffer}
<div
class="process-button-container"

View File

@ -0,0 +1,193 @@
import type { AudioProcessor } from "../processors/AudioProcessor";
const CROSSFADE_SAMPLES = 256;
/**
* Apply equal-power crossfade between two regions
*/
function applyCrossfade(
samples: Float32Array,
fadeOutStart: number,
fadeOutEnd: number,
fadeInStart: number,
fadeInEnd: number
): void {
const fadeLength = Math.min(fadeOutEnd - fadeOutStart, fadeInEnd - fadeInStart);
for (let i = 0; i < fadeLength; i++) {
const t = i / fadeLength;
const fadeOutGain = Math.cos(t * Math.PI * 0.5);
const fadeInGain = Math.sin(t * Math.PI * 0.5);
const fadeOutIdx = fadeOutStart + i;
const fadeInIdx = fadeInStart + i;
if (fadeOutIdx < samples.length && fadeInIdx < samples.length) {
const fadeOutSample = samples[fadeOutIdx] * fadeOutGain;
const fadeInSample = samples[fadeInIdx] * fadeInGain;
samples[fadeOutIdx] = fadeOutSample + fadeInSample;
}
}
}
/**
* Extract a portion of the audio buffer (crop to selection)
*/
export function cropAudio(
buffer: AudioBuffer,
startSample: number,
endSample: number,
sampleRate: number
): [Float32Array, Float32Array] {
const start = Math.max(0, Math.floor(startSample));
const end = Math.min(buffer.length, Math.ceil(endSample));
const length = end - start;
const leftChannel = buffer.getChannelData(0);
const rightChannel = buffer.getChannelData(1);
const newLeft = new Float32Array(length);
const newRight = new Float32Array(length);
for (let i = 0; i < length; i++) {
newLeft[i] = leftChannel[start + i];
newRight[i] = rightChannel[start + i];
}
return [newLeft, newRight];
}
/**
* Remove a portion of the audio buffer (cut with crossfade)
*/
export function cutAudio(
buffer: AudioBuffer,
startSample: number,
endSample: number,
sampleRate: number
): [Float32Array, Float32Array] {
const start = Math.max(0, Math.floor(startSample));
const end = Math.min(buffer.length, Math.ceil(endSample));
const leftChannel = buffer.getChannelData(0);
const rightChannel = buffer.getChannelData(1);
const beforeLength = start;
const afterLength = buffer.length - end;
const newLength = beforeLength + afterLength;
const newLeft = new Float32Array(newLength);
const newRight = new Float32Array(newLength);
for (let i = 0; i < beforeLength; i++) {
newLeft[i] = leftChannel[i];
newRight[i] = rightChannel[i];
}
for (let i = 0; i < afterLength; i++) {
newLeft[beforeLength + i] = leftChannel[end + i];
newRight[beforeLength + i] = rightChannel[end + i];
}
const crossfadeLength = Math.min(CROSSFADE_SAMPLES, beforeLength, afterLength);
if (crossfadeLength > 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];
}

View File

@ -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 @@
}
</script>
<canvas bind:this={canvas} onclick={handleClick} style="cursor: pointer;"></canvas>
<svelte:window onmouseup={handleMouseUp} ontouchend={handleTouchEnd} />
<canvas
bind:this={canvas}
onclick={handleClick}
onmousedown={handleMouseDown}
onmousemove={handleMouseMove}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
style="cursor: {isDragging ? 'text' : 'pointer'}; touch-action: none;"
></canvas>
<style>
canvas {