summaryrefslogtreecommitdiff
path: root/apps/web/src/voiceover/enhancements.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/voiceover/enhancements.ts')
-rw-r--r--apps/web/src/voiceover/enhancements.ts321
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));
+ }
+}