diff --git a/frontend/src/composables/useSilenceDetector.ts b/frontend/src/composables/useSilenceDetector.ts new file mode 100644 index 0000000..04295b2 --- /dev/null +++ b/frontend/src/composables/useSilenceDetector.ts @@ -0,0 +1,66 @@ +import { ref, readonly } from 'vue' + +export interface SilenceDetectorOptions { + thresholdDb?: number // default -40 + silenceDurationMs?: number // default 1500 + minRecordingMs?: number // default 500 +} + +export function useSilenceDetector(options: SilenceDetectorOptions = {}) { + const { + thresholdDb = -40, + silenceDurationMs = 1500, + minRecordingMs = 500, + } = options + + const amplitude = ref(0) + let audioCtx: AudioContext | null = null + let intervalId: ReturnType | null = null + let silenceMs = 0 + let startedAt = 0 + + function start(stream: MediaStream, onSilence: () => void): void { + stop() + audioCtx = new AudioContext() + const source = audioCtx.createMediaStreamSource(stream) + const analyser = audioCtx.createAnalyser() + analyser.fftSize = 256 + source.connect(analyser) + + const data = new Uint8Array(analyser.frequencyBinCount) + silenceMs = 0 + startedAt = Date.now() + + intervalId = setInterval(() => { + analyser.getByteFrequencyData(data) + const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255 + amplitude.value = rms + + const db = rms > 0 ? 20 * Math.log10(rms) : -100 + if (db < thresholdDb) { + silenceMs += 100 + if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) { + stop() + onSilence() + } + } else { + silenceMs = 0 + } + }, 100) + } + + function stop(): void { + if (intervalId !== null) { + clearInterval(intervalId) + intervalId = null + } + if (audioCtx) { + audioCtx.close().catch(() => {}) + audioCtx = null + } + amplitude.value = 0 + silenceMs = 0 + } + + return { amplitude: readonly(amplitude), start, stop } +}