Region processing
This commit is contained in:
@ -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"
|
||||
|
||||
193
src/lib/audio/utils/AudioEdit.ts
Normal file
193
src/lib/audio/utils/AudioEdit.ts
Normal 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];
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user