diff options
Diffstat (limited to 'apps/web/src/voiceover/enhancements.ts')
| -rw-r--r-- | apps/web/src/voiceover/enhancements.ts | 321 |
1 files changed, 321 insertions, 0 deletions
diff --git a/apps/web/src/voiceover/enhancements.ts b/apps/web/src/voiceover/enhancements.ts new file mode 100644 index 00000000..8718406f --- /dev/null +++ b/apps/web/src/voiceover/enhancements.ts @@ -0,0 +1,321 @@ +"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<Blob> { + 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<AudioBuffer> { + 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)); + } +} |
