feat: complete Phase 9.3 - automation recording with write/touch/latch modes

Implemented comprehensive automation recording system for volume, pan, and effect parameters:

- Added automation recording modes:
  - Write: Records continuously during playback when values change
  - Touch: Records only while control is being touched/moved
  - Latch: Records from first touch until playback stops

- Implemented value change detection (0.001 threshold) to prevent infinite loops
- Fixed React setState-in-render errors by:
  - Using queueMicrotask() to defer state updates
  - Moving lane creation logic to useEffect
  - Properly memoizing touch handlers with useMemo

- Added proper value ranges for effect parameters:
  - Frequency: 20-20000 Hz
  - Q: 0.1-20
  - Gain: -40-40 dB

- Enhanced automation lane auto-creation with parameter-specific ranges
- Added touch callbacks to all parameter controls (volume, pan, effects)
- Implemented throttling (100ms) to avoid excessive automation points

Technical improvements:
- Used tracksRef and onRecordAutomationRef to ensure latest values in animation loops
- Added proper cleanup on playback stop
- Optimized recording to only trigger when values actually change

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-18 23:29:18 +01:00
parent a1f230a6e6
commit c54d5089c5
13 changed files with 1040 additions and 70 deletions

134
lib/audio/export.ts Normal file
View File

@@ -0,0 +1,134 @@
/**
* Audio export utilities
* Supports WAV export with various bit depths
*/
export interface ExportOptions {
format: 'wav';
bitDepth: 16 | 24 | 32;
sampleRate?: number; // If different from source, will resample
normalize?: boolean; // Normalize to prevent clipping
}
/**
* Convert an AudioBuffer to WAV file
*/
export function audioBufferToWav(
audioBuffer: AudioBuffer,
options: ExportOptions = { format: 'wav', bitDepth: 16 }
): ArrayBuffer {
const { bitDepth, normalize } = options;
const numberOfChannels = audioBuffer.numberOfChannels;
const sampleRate = audioBuffer.sampleRate;
const length = audioBuffer.length;
// Get channel data
const channels: Float32Array[] = [];
for (let i = 0; i < numberOfChannels; i++) {
channels.push(audioBuffer.getChannelData(i));
}
// Find peak if normalizing
let peak = 1.0;
if (normalize) {
peak = 0;
for (const channel of channels) {
for (let i = 0; i < channel.length; i++) {
const abs = Math.abs(channel[i]);
if (abs > peak) peak = abs;
}
}
// Prevent division by zero and add headroom
if (peak === 0) peak = 1.0;
else peak = peak * 1.01; // 1% headroom
}
// Calculate sizes
const bytesPerSample = bitDepth / 8;
const blockAlign = numberOfChannels * bytesPerSample;
const dataSize = length * blockAlign;
const bufferSize = 44 + dataSize; // 44 bytes for WAV header
// Create buffer
const buffer = new ArrayBuffer(bufferSize);
const view = new DataView(buffer);
// Write WAV header
let offset = 0;
// RIFF chunk descriptor
writeString(view, offset, 'RIFF'); offset += 4;
view.setUint32(offset, bufferSize - 8, true); offset += 4; // File size - 8
writeString(view, offset, 'WAVE'); offset += 4;
// fmt sub-chunk
writeString(view, offset, 'fmt '); offset += 4;
view.setUint32(offset, 16, true); offset += 4; // Subchunk size (16 for PCM)
view.setUint16(offset, bitDepth === 32 ? 3 : 1, true); offset += 2; // Audio format (1 = PCM, 3 = IEEE float)
view.setUint16(offset, numberOfChannels, true); offset += 2;
view.setUint32(offset, sampleRate, true); offset += 4;
view.setUint32(offset, sampleRate * blockAlign, true); offset += 4; // Byte rate
view.setUint16(offset, blockAlign, true); offset += 2;
view.setUint16(offset, bitDepth, true); offset += 2;
// data sub-chunk
writeString(view, offset, 'data'); offset += 4;
view.setUint32(offset, dataSize, true); offset += 4;
// Write interleaved audio data
if (bitDepth === 16) {
for (let i = 0; i < length; i++) {
for (let channel = 0; channel < numberOfChannels; channel++) {
const sample = Math.max(-1, Math.min(1, channels[channel][i] / peak));
view.setInt16(offset, sample * 0x7fff, true);
offset += 2;
}
}
} else if (bitDepth === 24) {
for (let i = 0; i < length; i++) {
for (let channel = 0; channel < numberOfChannels; channel++) {
const sample = Math.max(-1, Math.min(1, channels[channel][i] / peak));
const int24 = Math.round(sample * 0x7fffff);
view.setUint8(offset, int24 & 0xff); offset++;
view.setUint8(offset, (int24 >> 8) & 0xff); offset++;
view.setUint8(offset, (int24 >> 16) & 0xff); offset++;
}
}
} else if (bitDepth === 32) {
for (let i = 0; i < length; i++) {
for (let channel = 0; channel < numberOfChannels; channel++) {
const sample = channels[channel][i] / peak;
view.setFloat32(offset, sample, true);
offset += 4;
}
}
}
return buffer;
}
/**
* Download an ArrayBuffer as a file
*/
export function downloadArrayBuffer(
arrayBuffer: ArrayBuffer,
filename: string,
mimeType: string = 'audio/wav'
): void {
const blob = new Blob([arrayBuffer], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
// Helper to write string to DataView
function writeString(view: DataView, offset: number, string: string): void {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}