diff --git a/src/app/hooks/__tests__/useAudioEnhancement.test.tsx b/src/app/hooks/__tests__/useAudioEnhancement.test.tsx new file mode 100644 index 00000000..daf8c55f --- /dev/null +++ b/src/app/hooks/__tests__/useAudioEnhancement.test.tsx @@ -0,0 +1,201 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RefObject } from 'react'; +import { useAudioEnhancement } from '../useAudioEnhancement'; + +type MockAudioParam = { + value: number; + setTargetAtTime: ReturnType; +}; + +type MockAudioNode = { + connect: ReturnType; +}; + +type MockBiquadFilter = MockAudioNode & { + type: BiquadFilterType; + frequency: MockAudioParam; + Q: MockAudioParam; + gain: MockAudioParam; +}; + +type MockCompressor = MockAudioNode & { + threshold: MockAudioParam; + knee: MockAudioParam; + ratio: MockAudioParam; + attack: MockAudioParam; + release: MockAudioParam; +}; + +type MockGain = MockAudioNode & { + gain: MockAudioParam; +}; + +const createAudioParam = (initialValue = 0): MockAudioParam => ({ + value: initialValue, + setTargetAtTime: vi.fn(function setTargetAtTime(this: MockAudioParam, value: number) { + this.value = value; + }), +}); + +const createNode = (): MockAudioNode => ({ + connect: vi.fn(), +}); + +const createBiquadFilter = (): MockBiquadFilter => ({ + ...createNode(), + type: 'peaking', + frequency: createAudioParam(), + Q: createAudioParam(), + gain: createAudioParam(), +}); + +const createCompressor = (): MockCompressor => ({ + ...createNode(), + threshold: createAudioParam(), + knee: createAudioParam(), + ratio: createAudioParam(), + attack: createAudioParam(), + release: createAudioParam(), +}); + +const createGain = (): MockGain => ({ + ...createNode(), + gain: createAudioParam(1), +}); + +describe('useAudioEnhancement', () => { + const originalAudioContext = window.AudioContext; + const originalWebkitAudioContext = window.webkitAudioContext; + + let createdFilters: MockBiquadFilter[]; + let createdCompressors: MockCompressor[]; + let createdGains: MockGain[]; + let resume: ReturnType; + let close: ReturnType; + + beforeEach(() => { + createdFilters = []; + createdCompressors = []; + createdGains = []; + resume = vi.fn(() => Promise.resolve()); + close = vi.fn(() => Promise.resolve()); + + class MockAudioContext { + currentTime = 4; + destination = createNode(); + resume = resume; + close = close; + + createMediaElementSource() { + return createNode(); + } + + createBiquadFilter() { + const filter = createBiquadFilter(); + createdFilters.push(filter); + return filter; + } + + createDynamicsCompressor() { + const compressor = createCompressor(); + createdCompressors.push(compressor); + return compressor; + } + + createGain() { + const gain = createGain(); + createdGains.push(gain); + return gain; + } + } + + Object.defineProperty(window, 'AudioContext', { + configurable: true, + writable: true, + value: MockAudioContext, + }); + Object.defineProperty(window, 'webkitAudioContext', { + configurable: true, + writable: true, + value: undefined, + }); + }); + + afterEach(() => { + Object.defineProperty(window, 'AudioContext', { + configurable: true, + writable: true, + value: originalAudioContext, + }); + Object.defineProperty(window, 'webkitAudioContext', { + configurable: true, + writable: true, + value: originalWebkitAudioContext, + }); + }); + + it('builds a speech-focused enhancement graph when enabled', () => { + const video = document.createElement('video'); + const videoRef = { current: video } as RefObject; + const { result } = renderHook(() => useAudioEnhancement(videoRef)); + + act(() => result.current.toggle()); + + expect(result.current.enabled).toBe(true); + expect(resume).toHaveBeenCalledTimes(1); + expect(createdFilters.map((filter) => filter.type)).toEqual([ + 'highpass', + 'lowpass', + 'lowshelf', + 'peaking', + ]); + expect(createdCompressors).toHaveLength(1); + expect(createdGains).toHaveLength(1); + + expect(createdFilters[0].frequency.value).toBe(68); + expect(createdFilters[1].frequency.value).toBe(16280); + expect(createdFilters[2].gain.value).toBe(6); + expect(createdFilters[3].gain.value).toBe(6); + expect(createdCompressors[0].threshold.value).toBe(-27.6); + expect(createdGains[0].gain.value).toBe(1.018); + }); + + it('clamps enhancement controls before applying them', () => { + const video = document.createElement('video'); + const videoRef = { current: video } as RefObject; + const { result } = renderHook(() => useAudioEnhancement(videoRef)); + + act(() => result.current.toggle()); + act(() => { + result.current.setBassBoost(50); + result.current.setVoiceClarity(-4); + result.current.setNoiseReduction(2); + }); + + expect(result.current.bassBoost).toBe(20); + expect(result.current.voiceClarity).toBe(0); + expect(result.current.noiseReduction).toBe(1); + expect(createdFilters[0].frequency.value).toBe(180); + expect(createdFilters[1].frequency.value).toBe(7600); + expect(createdFilters[2].gain.value).toBe(20); + expect(createdFilters[3].gain.value).toBe(0); + }); + + it('does not expose enhancement controls without Web Audio support', () => { + Object.defineProperty(window, 'AudioContext', { + configurable: true, + writable: true, + value: undefined, + }); + + const videoRef = { current: document.createElement('video') } as RefObject; + const { result } = renderHook(() => useAudioEnhancement(videoRef)); + + act(() => result.current.toggle()); + + expect(result.current.isSupported).toBe(false); + expect(result.current.enabled).toBe(false); + expect(resume).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/hooks/useAudioEnhancement.ts b/src/app/hooks/useAudioEnhancement.ts index 84d58297..bdfa1388 100644 --- a/src/app/hooks/useAudioEnhancement.ts +++ b/src/app/hooks/useAudioEnhancement.ts @@ -3,11 +3,17 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import type { RefObject } from 'react'; +declare global { + interface Window { + webkitAudioContext?: typeof AudioContext; + } +} + export interface AudioEnhancementState { enabled: boolean; - bassBoost: number; // 0–20 dB - voiceClarity: number; // 0–20 dB - noiseReduction: number; // 0–1 (gain reduction) + bassBoost: number; // 0-20 dB + voiceClarity: number; // 0-20 dB + noiseReduction: number; // 0-1 speech-focused filtering amount } export interface UseAudioEnhancementReturn extends AudioEnhancementState { @@ -25,77 +31,136 @@ const DEFAULT_STATE: AudioEnhancementState = { noiseReduction: 0.3, }; +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +const getAudioContextConstructor = () => { + if (typeof window === 'undefined') return undefined; + return window.AudioContext ?? window.webkitAudioContext; +}; + +const setAudioParam = (param: AudioParam, value: number, context: AudioContext) => { + if (typeof param.setTargetAtTime === 'function') { + param.setTargetAtTime(value, context.currentTime, 0.015); + return; + } + + param.value = value; +}; + export function useAudioEnhancement( videoRef: RefObject, ): UseAudioEnhancementReturn { - const isSupported = typeof window !== 'undefined' && 'AudioContext' in window; + const isSupported = !!getAudioContextConstructor(); const [state, setState] = useState(DEFAULT_STATE); const audioCtxRef = useRef(null); const sourceRef = useRef(null); + const highPassFilterRef = useRef(null); + const lowPassFilterRef = useRef(null); const bassFilterRef = useRef(null); - const midFilterRef = useRef(null); - const gainRef = useRef(null); + const voiceFilterRef = useRef(null); + const compressorRef = useRef(null); + const outputGainRef = useRef(null); const connectedRef = useRef(false); // Build the audio graph once the video element is available. const initGraph = useCallback(() => { if (!isSupported || !videoRef.current || connectedRef.current) return; - const ctx = new AudioContext(); + const AudioContextConstructor = getAudioContextConstructor(); + if (!AudioContextConstructor) return; + + const ctx = new AudioContextConstructor(); audioCtxRef.current = ctx; const source = ctx.createMediaElementSource(videoRef.current); sourceRef.current = source; - // Bass boost — low-shelf filter around 100 Hz + const highPass = ctx.createBiquadFilter(); + highPass.type = 'highpass'; + highPass.frequency.value = 20; + highPass.Q.value = 0.7; + highPassFilterRef.current = highPass; + + const lowPass = ctx.createBiquadFilter(); + lowPass.type = 'lowpass'; + lowPass.frequency.value = 20000; + lowPass.Q.value = 0.7; + lowPassFilterRef.current = lowPass; + + // Bass boost: low-shelf filter around 100 Hz. const bass = ctx.createBiquadFilter(); bass.type = 'lowshelf'; bass.frequency.value = 100; - bass.gain.value = 0; // off by default + bass.gain.value = 0; bassFilterRef.current = bass; - // Voice clarity — peaking filter around 3 kHz - const mid = ctx.createBiquadFilter(); - mid.type = 'peaking'; - mid.frequency.value = 3000; - mid.Q.value = 1; - mid.gain.value = 0; // off by default - midFilterRef.current = mid; - - // Noise reduction approximated via a gain node (subtle attenuation) - const gain = ctx.createGain(); - gain.gain.value = 1; // off by default - gainRef.current = gain; - - source.connect(bass); - bass.connect(mid); - mid.connect(gain); - gain.connect(ctx.destination); + // Voice clarity: broad speech presence boost. + const voice = ctx.createBiquadFilter(); + voice.type = 'peaking'; + voice.frequency.value = 3000; + voice.Q.value = 1; + voice.gain.value = 0; + voiceFilterRef.current = voice; + + const compressor = ctx.createDynamicsCompressor(); + compressor.threshold.value = -24; + compressor.knee.value = 18; + compressor.ratio.value = 3; + compressor.attack.value = 0.006; + compressor.release.value = 0.18; + compressorRef.current = compressor; + + const outputGain = ctx.createGain(); + outputGain.gain.value = 1; + outputGainRef.current = outputGain; + + source.connect(highPass); + highPass.connect(lowPass); + lowPass.connect(bass); + bass.connect(voice); + voice.connect(compressor); + compressor.connect(outputGain); + outputGain.connect(ctx.destination); connectedRef.current = true; }, [isSupported, videoRef]); // Apply current state values to the audio nodes. const applyState = useCallback((next: AudioEnhancementState) => { - if (!connectedRef.current) return; + const context = audioCtxRef.current; + if (!connectedRef.current || !context) return; const bassGain = next.enabled ? next.bassBoost : 0; - const midGain = next.enabled ? next.voiceClarity : 0; - // Noise reduction: reduce gain slightly when enabled - const gainValue = next.enabled ? 1 - next.noiseReduction * 0.2 : 1; - - if (bassFilterRef.current) bassFilterRef.current.gain.value = bassGain; - if (midFilterRef.current) midFilterRef.current.gain.value = midGain; - if (gainRef.current) gainRef.current.gain.value = gainValue; + const voiceGain = next.enabled ? next.voiceClarity : 0; + const noiseReduction = next.enabled ? next.noiseReduction : 0; + + // Trim low rumble and high-frequency hiss without globally lowering volume. + const highPassFrequency = 20 + noiseReduction * 160; + const lowPassFrequency = 20000 - noiseReduction * 12400; + const compressorThreshold = -24 - noiseReduction * 12; + const outputGain = next.enabled ? 1 + noiseReduction * 0.06 : 1; + + if (highPassFilterRef.current) { + setAudioParam(highPassFilterRef.current.frequency, highPassFrequency, context); + } + if (lowPassFilterRef.current) { + setAudioParam(lowPassFilterRef.current.frequency, lowPassFrequency, context); + } + if (bassFilterRef.current) setAudioParam(bassFilterRef.current.gain, bassGain, context); + if (voiceFilterRef.current) setAudioParam(voiceFilterRef.current.gain, voiceGain, context); + if (compressorRef.current) { + setAudioParam(compressorRef.current.threshold, compressorThreshold, context); + } + if (outputGainRef.current) setAudioParam(outputGainRef.current.gain, outputGain, context); }, []); const toggle = useCallback(() => { if (!isSupported) return; initGraph(); - // Resume AudioContext if suspended (browser autoplay policy) - audioCtxRef.current?.resume(); + // Resume AudioContext if suspended by browser autoplay policy. + void audioCtxRef.current?.resume(); setState((prev) => { const next = { ...prev, enabled: !prev.enabled }; applyState(next); @@ -106,7 +171,7 @@ export function useAudioEnhancement( const setBassBoost = useCallback( (value: number) => { setState((prev) => { - const next = { ...prev, bassBoost: value }; + const next = { ...prev, bassBoost: clamp(value, 0, 20) }; applyState(next); return next; }); @@ -117,7 +182,7 @@ export function useAudioEnhancement( const setVoiceClarity = useCallback( (value: number) => { setState((prev) => { - const next = { ...prev, voiceClarity: value }; + const next = { ...prev, voiceClarity: clamp(value, 0, 20) }; applyState(next); return next; }); @@ -128,7 +193,7 @@ export function useAudioEnhancement( const setNoiseReduction = useCallback( (value: number) => { setState((prev) => { - const next = { ...prev, noiseReduction: value }; + const next = { ...prev, noiseReduction: clamp(value, 0, 1) }; applyState(next); return next; }); @@ -139,7 +204,7 @@ export function useAudioEnhancement( // Cleanup on unmount. useEffect(() => { return () => { - audioCtxRef.current?.close(); + void audioCtxRef.current?.close(); connectedRef.current = false; }; }, []);