export type AudioVolumeClassification = "silent" | "low" | "good"; export interface AudioClipVolumeAnalysis { classification: AudioVolumeClassification; analyzedMs: number; frameCount: number; averageRms: number; averageAudibleRms: number; peakRms: number; averageDb: number; peakDb: number; silentRatio: number; lowVolumeRatio: number; } export interface AudioLevelStats { audibleFrameCount: number; audibleRmsTotal: number; frameCount: number; lowVolumeFrameCount: number; peakRms: number; rmsTotal: number; } interface AudioLevelAnalyzer { stop(): Promise; } type AudioContextConstructor = typeof AudioContext; const SAMPLE_INTERVAL_MS = 100; const SILENCE_RMS_THRESHOLD = 0.006; const GOOD_RMS_THRESHOLD = 0.025; const MIN_AUDIBLE_MS = 500; export function createAudioLevelAnalyzer( stream: MediaStream, ): AudioLevelAnalyzer | null { if (stream.getAudioTracks().length === 0) return null; const AudioContextCtor = resolveAudioContextConstructor(); if (!AudioContextCtor) return null; let context: AudioContext; let source: MediaStreamAudioSourceNode; let analyser: AnalyserNode; try { context = new AudioContextCtor(); source = context.createMediaStreamSource(stream); analyser = context.createAnalyser(); } catch { return null; } analyser.fftSize = 2048; analyser.smoothingTimeConstant = 0.2; source.connect(analyser); const samples = new Float32Array(analyser.fftSize); const stats: AudioLevelStats = { audibleFrameCount: 0, audibleRmsTotal: 0, frameCount: 0, lowVolumeFrameCount: 0, peakRms: 0, rmsTotal: 0, }; const startedAt = now(); let timer: ReturnType | null = null; let stopped = false; const sample = (): void => { if (stopped) return; analyser.getFloatTimeDomainData(samples); recordRms(stats, rms(samples)); }; timer = setInterval(sample, SAMPLE_INTERVAL_MS); sample(); void context.resume?.().catch(() => undefined); return { async stop(): Promise { stopped = true; if (timer !== null) { clearInterval(timer); timer = null; } try { analyser.getFloatTimeDomainData(samples); recordRms(stats, rms(samples)); } catch { // The stream may already be ended; use the samples already collected. } try { source.disconnect(); analyser.disconnect(); } catch { // The audio graph may already be torn down. } if (context.state !== "closed") { await context.close().catch(() => undefined); } return summarizeAudioLevelStats(stats, now() - startedAt); }, }; } export function summarizeAudioLevelStats( stats: AudioLevelStats, analyzedMs: number, ): AudioClipVolumeAnalysis { const frameCount = Math.max(0, Math.floor(stats.frameCount)); const audibleFrameCount = Math.max(0, Math.floor(stats.audibleFrameCount)); const averageRms = frameCount > 0 ? stats.rmsTotal / frameCount : 0; const averageAudibleRms = audibleFrameCount > 0 ? stats.audibleRmsTotal / audibleFrameCount : 0; const audibleRatio = frameCount > 0 ? audibleFrameCount / frameCount : 0; const silentRatio = 1 - audibleRatio; const lowVolumeRatio = frameCount > 0 ? stats.lowVolumeFrameCount / frameCount : 0; const audibleMs = Math.max(0, analyzedMs) * audibleRatio; const peakRms = Math.max(0, stats.peakRms); let classification: AudioVolumeClassification; if ( frameCount === 0 || peakRms < SILENCE_RMS_THRESHOLD || audibleMs < MIN_AUDIBLE_MS ) { classification = "silent"; } else if (averageAudibleRms < GOOD_RMS_THRESHOLD) { classification = "low"; } else { classification = "good"; } return { classification, analyzedMs: Math.max(0, Math.round(analyzedMs)), frameCount, averageRms: roundMetric(averageRms), averageAudibleRms: roundMetric(averageAudibleRms), peakRms: roundMetric(peakRms), averageDb: roundMetric(rmsToDb(averageRms), 1), peakDb: roundMetric(rmsToDb(peakRms), 1), silentRatio: roundMetric(silentRatio), lowVolumeRatio: roundMetric(lowVolumeRatio), }; } function recordRms(stats: AudioLevelStats, value: number): void { stats.frameCount += 1; stats.rmsTotal += value; stats.peakRms = Math.max(stats.peakRms, value); if (value >= SILENCE_RMS_THRESHOLD) { stats.audibleFrameCount += 1; stats.audibleRmsTotal += value; if (value < GOOD_RMS_THRESHOLD) { stats.lowVolumeFrameCount += 1; } } } function rms(samples: Float32Array): number { let sum = 0; for (const sample of samples) { sum += sample * sample; } return Math.sqrt(sum / Math.max(1, samples.length)); } function rmsToDb(value: number): number { if (value <= 0) return -100; return Math.max(-100, 20 * Math.log10(value)); } function roundMetric(value: number, decimals = 4): number { const scale = 10 ** decimals; return Math.round(value * scale) / scale; } function resolveAudioContextConstructor(): | AudioContextConstructor | undefined { const root = globalThis as typeof globalThis & { webkitAudioContext?: AudioContextConstructor; }; return root.AudioContext ?? root.webkitAudioContext; } function now(): number { return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now(); }