import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createNoAudioDetector } from '../no-audio-detector'; import type { NoAudioDetectorOptions } from '../no-audio-detector'; import { mockAudioStream } from '../../devices/__tests__/mocks'; import { setupAudioContextMock, cleanupAudioContextMock, createMockAnalyserNode, } from '../../devices/__tests__/web-audio.mocks'; describe('no-audio-detector (browser)', () => { let mockAudioContext: ReturnType; let audioStream: MediaStream; type MockAnalyserNode = ReturnType & { getByteTimeDomainData: ReturnType; }; const getAnalyserNode = () => { const analyserNode = vi .mocked(mockAudioContext.createAnalyser!) .mock.results.at(-1)?.value as MockAnalyserNode | undefined; expect(analyserNode).toBeDefined(); return analyserNode!; }; const createDetector = (overrides?: Partial) => { const onCaptureStatusChange = vi.fn(); createNoAudioDetector(audioStream, { onCaptureStatusChange, noAudioThresholdMs: 5000, emitIntervalMs: 5000, detectionFrequencyInMs: 500, ...overrides, }); return { onCaptureStatusChange, analyserNode: getAnalyserNode() }; }; const setAudioLevel = (analyserNode: MockAnalyserNode, level: number) => { const amplitude = Math.min(Math.max(level, 0), 127); vi.mocked(analyserNode.getByteTimeDomainData).mockImplementation( (array) => { if (amplitude === 0) { array.fill(128); return; } for (let i = 0; i < array.length; i++) { array[i] = i % 2 === 0 ? 128 + amplitude : 128 - amplitude; } }, ); }; beforeEach(() => { vi.useFakeTimers(); mockAudioContext = setupAudioContextMock(); audioStream = mockAudioStream(); }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); cleanupAudioContextMock(); }); describe('detector lifecycle', () => { it('should clean up AudioContext and clear interval on stop', async () => { const onCaptureStatusChange = vi.fn(); const stop = createNoAudioDetector(audioStream, { onCaptureStatusChange, noAudioThresholdMs: 5000, emitIntervalMs: 5000, }); await stop(); expect(mockAudioContext.close).toHaveBeenCalled(); expect(mockAudioContext.state).toBe('closed'); }); }); describe('no audio detection', () => { it('should not emit event before threshold is reached', () => { const { onCaptureStatusChange, analyserNode } = createDetector(); setAudioLevel(analyserNode, 0); // Advance time but not past threshold vi.advanceTimersByTime(4500); expect(onCaptureStatusChange).not.toHaveBeenCalled(); }); it('should emit event after threshold is reached with no audio', () => { const { onCaptureStatusChange, analyserNode } = createDetector(); setAudioLevel(analyserNode, 0); // Advance past threshold (threshold + one interval period) vi.advanceTimersByTime(5500); expect(onCaptureStatusChange).toHaveBeenCalledWith(false); }); it('should treat tiny 127/128 jitter as no audio', () => { const { onCaptureStatusChange, analyserNode } = createDetector(); setAudioLevel(analyserNode, 1); vi.advanceTimersByTime(5500); expect(onCaptureStatusChange).toHaveBeenCalledTimes(1); expect(onCaptureStatusChange).toHaveBeenLastCalledWith(false); }); it('should respect custom emit interval', () => { const { onCaptureStatusChange, analyserNode } = createDetector({ noAudioThresholdMs: 3000, emitIntervalMs: 2000, // Different from threshold }); setAudioLevel(analyserNode, 0); // First event at 3.5s (3s threshold + 500ms first interval) vi.advanceTimersByTime(3500); expect(onCaptureStatusChange).toHaveBeenCalledTimes(1); // Second event at 5.5s (3.5s + 2s interval) vi.advanceTimersByTime(2000); expect(onCaptureStatusChange).toHaveBeenCalledTimes(2); // Third event at 7.5s (5.5s + 2s interval) vi.advanceTimersByTime(2000); expect(onCaptureStatusChange).toHaveBeenCalledTimes(3); }); }); describe('audio detection', () => { it('should stop and emit audio detected when sound appears before threshold', () => { const { onCaptureStatusChange, analyserNode } = createDetector(); setAudioLevel(analyserNode, 0); // Start in no-audio detecting mode but stay below threshold. vi.advanceTimersByTime(3000); expect(onCaptureStatusChange).not.toHaveBeenCalled(); // Audio appears before no-audio threshold is reached. setAudioLevel(analyserNode, 10); vi.advanceTimersByTime(500); expect(onCaptureStatusChange).toHaveBeenCalledTimes(1); expect(onCaptureStatusChange).toHaveBeenLastCalledWith(true); // Detector should be stopped. onCaptureStatusChange.mockClear(); vi.advanceTimersByTime(10000); expect(onCaptureStatusChange).not.toHaveBeenCalled(); }); it('should stop checking after audio is detected', () => { const { onCaptureStatusChange, analyserNode } = createDetector(); setAudioLevel(analyserNode, 0); // Emit no-audio event vi.advanceTimersByTime(5500); expect(onCaptureStatusChange).toHaveBeenCalledTimes(1); setAudioLevel(analyserNode, 10); vi.advanceTimersByTime(500); expect(onCaptureStatusChange).toHaveBeenCalledTimes(2); expect(onCaptureStatusChange).toHaveBeenLastCalledWith(true); // Clear mock to verify no more calls onCaptureStatusChange.mockClear(); // Advance time significantly - no more events should be emitted vi.advanceTimersByTime(10000); expect(onCaptureStatusChange).not.toHaveBeenCalled(); }); }); describe('track state handling', () => { it('should reset state when track becomes inactive', () => { const [track] = audioStream.getAudioTracks(); const { onCaptureStatusChange, analyserNode } = createDetector(); setAudioLevel(analyserNode, 0); // Advance partway to threshold vi.advanceTimersByTime(3000); track.enabled = false; // Advance detection cycle vi.advanceTimersByTime(500); track.enabled = true; // Should need to wait full threshold again from reset vi.advanceTimersByTime(3000); expect(onCaptureStatusChange).not.toHaveBeenCalled(); vi.advanceTimersByTime(2500); expect(onCaptureStatusChange).toHaveBeenCalledTimes(1); }); it('should emit no-audio when track ends', () => { const [track] = audioStream.getAudioTracks() as Array< Omit & { readyState: string } >; const { onCaptureStatusChange, analyserNode } = createDetector(); setAudioLevel(analyserNode, 0); // Advance partway vi.advanceTimersByTime(3000); track.readyState = 'ended'; // Advance detection cycle vi.advanceTimersByTime(500); // Ended track should be treated as no-audio and eventually emit. vi.advanceTimersByTime(5000); expect(onCaptureStatusChange).toHaveBeenCalledTimes(1); expect(onCaptureStatusChange).toHaveBeenCalledWith(false); }); }); describe('stopping behavior', () => { it('should stop completely after emitting capturesAudio true', () => { const { onCaptureStatusChange, analyserNode } = createDetector({ noAudioThresholdMs: 3000, }); // Start with no audio setAudioLevel(analyserNode, 0); // Emit first no-audio event vi.advanceTimersByTime(3500); expect(onCaptureStatusChange).toHaveBeenCalledTimes(1); expect(onCaptureStatusChange).toHaveBeenLastCalledWith(false); // Audio detected, should emit capturesAudio: true and stop setAudioLevel(analyserNode, 10); vi.advanceTimersByTime(500); expect(onCaptureStatusChange).toHaveBeenCalledTimes(2); expect(onCaptureStatusChange).toHaveBeenLastCalledWith(true); // Verify interval stopped and AudioContext closed onCaptureStatusChange.mockClear(); vi.advanceTimersByTime(10000); expect(onCaptureStatusChange).not.toHaveBeenCalled(); expect(mockAudioContext.close).toHaveBeenCalled(); }); it('should stop immediately if audio working from start', () => { const { onCaptureStatusChange, analyserNode } = createDetector({ noAudioThresholdMs: 3000, emitIntervalMs: 3000, }); // Start with audio working setAudioLevel(analyserNode, 10); // Should emit initial "audio working" event and stop vi.advanceTimersByTime(500); expect(onCaptureStatusChange).toHaveBeenCalledTimes(1); expect(onCaptureStatusChange).toHaveBeenCalledWith(true); // Verify stopped completely onCaptureStatusChange.mockClear(); vi.advanceTimersByTime(10000); expect(onCaptureStatusChange).not.toHaveBeenCalled(); expect(mockAudioContext.close).toHaveBeenCalled(); }); }); describe('edge cases', () => { it('should emit no-audio when stream has no audio tracks', async () => { const onCaptureStatusChange = vi.fn(); const emptyStream = { getAudioTracks: () => [], } as Partial as MediaStream; const stop = createNoAudioDetector(emptyStream, { onCaptureStatusChange, noAudioThresholdMs: 5000, emitIntervalMs: 5000, detectionFrequencyInMs: 500, }); // Missing track should be treated as no-audio and eventually emit. vi.advanceTimersByTime(10000); expect(onCaptureStatusChange).toHaveBeenCalledTimes(1); expect(onCaptureStatusChange).toHaveBeenCalledWith(false); // Cleanup should work await expect(stop()).resolves.toBeUndefined(); }); }); });