"use client"; export interface VoiceOverEnhancementSettings { voiceBoost: boolean; noiseGate: boolean; highPass: boolean; deEss: boolean; limiter: boolean; normalize: boolean; } export const DEFAULT_VOICE_OVER_ENHANCEMENTS: VoiceOverEnhancementSettings = { voiceBoost: true, noiseGate: true, highPass: true, deEss: false, limiter: true, normalize: true, }; export const VOICE_OVER_ENHANCEMENT_LABELS: Record< keyof VoiceOverEnhancementSettings, { label: string; description: string } > = { voiceBoost: { label: "Voice boost", description: "Adds compression and presence for a stronger voice.", }, noiseGate: { label: "Noise gate", description: "Turns down quiet room noise between phrases.", }, highPass: { label: "High-pass", description: "Removes low rumble and desk vibration.", }, deEss: { label: "De-ess", description: "Softens harsh S sounds and upper fizz.", }, limiter: { label: "Limiter", description: "Catches peaks after boosts.", }, normalize: { label: "Normalize", description: "Raises the finished take to a consistent level.", }, }; const WAV_BITS_PER_SAMPLE = 16; const WAV_HEADER_BYTES = 44; const TARGET_PEAK = 0.92; const NOISE_GATE_THRESHOLD = 0.018; const NOISE_GATE_FLOOR = 0.18; export function hasEnabledVoiceOverEnhancement({ settings, }: { settings: VoiceOverEnhancementSettings; }): boolean { return Object.values(settings).some(Boolean); } export async function applyVoiceOverEnhancementsToBlob({ blob, settings, }: { blob: Blob; settings: VoiceOverEnhancementSettings; }): Promise { if (!hasEnabledVoiceOverEnhancement({ settings })) { return blob; } const audioContext = createAudioContext(); try { const buffer = await audioContext.decodeAudioData(await blob.arrayBuffer()); const renderedBuffer = await renderEnhancedVoiceOverBuffer({ buffer, settings, }); return encodeAudioBufferToWav({ buffer: renderedBuffer }); } finally { void audioContext.close(); } } export async function renderEnhancedVoiceOverBuffer({ buffer, settings, }: { buffer: AudioBuffer; settings: VoiceOverEnhancementSettings; }): Promise { const offlineContext = new OfflineAudioContext( buffer.numberOfChannels, Math.max(1, buffer.length), buffer.sampleRate, ); const source = offlineContext.createBufferSource(); source.buffer = settings.noiseGate ? applyNoiseGateToBuffer({ buffer, context: offlineContext, threshold: NOISE_GATE_THRESHOLD, floor: NOISE_GATE_FLOOR, }) : buffer; let current: AudioNode = source; if (settings.highPass) { const highPass = offlineContext.createBiquadFilter(); highPass.type = "highpass"; highPass.frequency.value = 85; highPass.Q.value = 0.7; current.connect(highPass); current = highPass; } if (settings.voiceBoost) { const presence = offlineContext.createBiquadFilter(); presence.type = "peaking"; presence.frequency.value = 3200; presence.Q.value = 0.9; presence.gain.value = 2.5; const compressor = offlineContext.createDynamicsCompressor(); compressor.threshold.value = -24; compressor.knee.value = 12; compressor.ratio.value = 3.5; compressor.attack.value = 0.004; compressor.release.value = 0.16; const makeupGain = offlineContext.createGain(); makeupGain.gain.value = 1.45; current.connect(presence); presence.connect(compressor); compressor.connect(makeupGain); current = makeupGain; } if (settings.deEss) { const deEss = offlineContext.createBiquadFilter(); deEss.type = "peaking"; deEss.frequency.value = 6400; deEss.Q.value = 2.2; deEss.gain.value = -3; current.connect(deEss); current = deEss; } if (settings.limiter) { const limiter = offlineContext.createDynamicsCompressor(); limiter.threshold.value = -1; limiter.knee.value = 0; limiter.ratio.value = 20; limiter.attack.value = 0.001; limiter.release.value = 0.1; current.connect(limiter); current = limiter; } current.connect(offlineContext.destination); source.start(0); const rendered = await offlineContext.startRendering(); if (settings.normalize) { normalizeAudioBufferPeak({ buffer: rendered, targetPeak: TARGET_PEAK }); } clampAudioBuffer({ buffer: rendered, maxPeak: 0.98 }); return rendered; } export function encodeAudioBufferToWav({ buffer, }: { buffer: AudioBuffer; }): Blob { const numChannels = buffer.numberOfChannels; const bytesPerSample = WAV_BITS_PER_SAMPLE / 8; const dataSize = buffer.length * numChannels * bytesPerSample; const arrayBuffer = new ArrayBuffer(WAV_HEADER_BYTES + dataSize); const view = new DataView(arrayBuffer); writeAsciiString({ view, offset: 0, value: "RIFF" }); view.setUint32(4, 36 + dataSize, true); writeAsciiString({ view, offset: 8, value: "WAVE" }); writeAsciiString({ view, offset: 12, value: "fmt " }); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, numChannels, true); view.setUint32(24, buffer.sampleRate, true); view.setUint32(28, buffer.sampleRate * numChannels * bytesPerSample, true); view.setUint16(32, numChannels * bytesPerSample, true); view.setUint16(34, WAV_BITS_PER_SAMPLE, true); writeAsciiString({ view, offset: 36, value: "data" }); view.setUint32(40, dataSize, true); let offset = WAV_HEADER_BYTES; for (let sampleIndex = 0; sampleIndex < buffer.length; sampleIndex++) { for (let channel = 0; channel < numChannels; channel++) { const sample = Math.max( -1, Math.min(1, buffer.getChannelData(channel)[sampleIndex] ?? 0), ); const int16 = sample < 0 ? sample * 0x8000 : sample * 0x7fff; view.setInt16(offset, int16, true); offset += bytesPerSample; } } return new Blob([arrayBuffer], { type: "audio/wav" }); } export function getAudioBufferPeak({ buffer }: { buffer: AudioBuffer }): number { let peak = 0; for (let channel = 0; channel < buffer.numberOfChannels; channel++) { const channelData = buffer.getChannelData(channel); for (let index = 0; index < channelData.length; index++) { peak = Math.max(peak, Math.abs(channelData[index] ?? 0)); } } return peak; } function normalizeAudioBufferPeak({ buffer, targetPeak, }: { buffer: AudioBuffer; targetPeak: number; }): void { const peak = getAudioBufferPeak({ buffer }); if (peak <= 0 || peak >= targetPeak) { return; } const gain = targetPeak / peak; for (let channel = 0; channel < buffer.numberOfChannels; channel++) { const channelData = buffer.getChannelData(channel); for (let index = 0; index < channelData.length; index++) { channelData[index] *= gain; } } } function clampAudioBuffer({ buffer, maxPeak, }: { buffer: AudioBuffer; maxPeak: number; }): void { for (let channel = 0; channel < buffer.numberOfChannels; channel++) { const channelData = buffer.getChannelData(channel); for (let index = 0; index < channelData.length; index++) { channelData[index] = Math.max( -maxPeak, Math.min(maxPeak, channelData[index] ?? 0), ); } } } function applyNoiseGateToBuffer({ buffer, context, threshold, floor, }: { buffer: AudioBuffer; context: OfflineAudioContext; threshold: number; floor: number; }): AudioBuffer { const gated = context.createBuffer( buffer.numberOfChannels, buffer.length, buffer.sampleRate, ); for (let channel = 0; channel < buffer.numberOfChannels; channel++) { const input = buffer.getChannelData(channel); const output = gated.getChannelData(channel); for (let index = 0; index < input.length; index++) { const sample = input[index] ?? 0; output[index] = Math.abs(sample) < threshold ? sample * floor : sample; } } return gated; } function createAudioContext(): AudioContext { const AudioContextConstructor = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }) .webkitAudioContext; return new AudioContextConstructor(); } function writeAsciiString({ view, offset, value, }: { view: DataView; offset: number; value: string; }): void { for (let index = 0; index < value.length; index++) { view.setUint8(offset + index, value.charCodeAt(index)); } }