From e74f71e45b261baff469e184c891fddc79f7fb82 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Thu, 30 Apr 2026 06:13:49 +0900 Subject: [PATCH] Warn when Windows local input is silent --- src/audio/local-voice-session.ts | 70 +++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/src/audio/local-voice-session.ts b/src/audio/local-voice-session.ts index 8541e7c..c81965d 100644 --- a/src/audio/local-voice-session.ts +++ b/src/audio/local-voice-session.ts @@ -33,6 +33,7 @@ export class LocalVoiceSession { private readonly memory: ConversationMemory; private readonly queue: SpeechJob[] = []; private readonly pendingSamples: number[] = []; + private readonly silenceThreshold = 900; private vad: RealTimeVAD | null = null; private recorder: ChildProcessByStdio | null = null; @@ -42,6 +43,12 @@ export class LocalVoiceSession { private processing = Promise.resolve(); private draining = false; private destroyed = false; + private inputWatchdog: NodeJS.Timeout | null = null; + private recorderStartedAt = 0; + private lastPcmChunkAt = 0; + private lastNonSilentAudioAt = 0; + private warnedNoPcm = false; + private warnedSilent = false; constructor(private readonly options: LocalVoiceSessionOptions) { this.memory = new ConversationMemory(options.config.MAX_CONVERSATION_TURNS); @@ -69,6 +76,11 @@ export class LocalVoiceSession { }); this.recorder = this.spawnRecorder(); + this.recorderStartedAt = Date.now(); + this.lastPcmChunkAt = 0; + this.lastNonSilentAudioAt = 0; + this.warnedNoPcm = false; + this.warnedSilent = false; this.recorder.stdout.on("data", (chunk: Buffer) => { this.pushPcm16Chunk(chunk); }); @@ -83,12 +95,21 @@ export class LocalVoiceSession { this.options.logger.warn("pw-record exited unexpectedly", { code, signal }); } }); + + this.inputWatchdog = setInterval(() => { + this.reportInputHealth(); + }, 3_000); } async destroy(): Promise { this.destroyed = true; this.interruptPlayback("local-shutdown"); + if (this.inputWatchdog) { + clearInterval(this.inputWatchdog); + this.inputWatchdog = null; + } + if (this.recorder && !this.recorder.killed) { this.recorder.kill("SIGTERM"); await once(this.recorder, "exit").catch(() => null); @@ -195,8 +216,20 @@ export class LocalVoiceSession { return; } + this.lastPcmChunkAt = Date.now(); + let peak = 0; + for (let offset = 0; offset + 1 < chunk.length; offset += 2) { - this.pendingSamples.push(chunk.readInt16LE(offset)); + const sample = chunk.readInt16LE(offset); + const abs = Math.abs(sample); + if (abs > peak) { + peak = abs; + } + this.pendingSamples.push(sample); + } + + if (peak >= this.silenceThreshold) { + this.lastNonSilentAudioAt = Date.now(); } while (true) { @@ -216,6 +249,7 @@ export class LocalVoiceSession { private async handleSpeechEnd(audio: Float32Array): Promise { if (audio.length < 16000 * 0.25) { + this.options.logger.debug("Ignored short local speech segment", { samples: audio.length }); return; } @@ -234,6 +268,7 @@ export class LocalVoiceSession { } if (!transcript || transcript.trim().length === 0) { + this.options.logger.info("Local STT returned empty transcript"); return; } @@ -463,6 +498,39 @@ export class LocalVoiceSession { return requireFfmpegPath(); } + private reportInputHealth(): void { + if (this.destroyed) { + return; + } + + const now = Date.now(); + + if (!this.warnedNoPcm && this.lastPcmChunkAt === 0 && now - this.recorderStartedAt >= 6_000) { + this.warnedNoPcm = true; + this.options.logger.warn( + [ + "입력 장치에서 PCM 데이터가 들어오지 않습니다.", + `현재 source: ${this.options.config.LOCAL_AUDIO_SOURCE ?? "default"}`, + "Windows에서는 마이크 입력이 아니라 SPDIF/ADAT 같은 디지털 입력을 고르면 반응이 없습니다.", + "`bun run devices`로 실제 마이크 이름을 다시 고르세요.", + ].join("\n"), + ); + return; + } + + if (!this.warnedSilent && this.lastPcmChunkAt > 0 && this.lastNonSilentAudioAt === 0 && now - this.recorderStartedAt >= 6_000) { + this.warnedSilent = true; + this.options.logger.warn( + [ + "입력 장치에서는 데이터가 오지만 말소리 수준으로 올라오지 않습니다.", + `현재 source: ${this.options.config.LOCAL_AUDIO_SOURCE ?? "default"}`, + "잘못된 입력 채널이거나, 마이크가 그 장치로 라우팅되지 않은 상태일 가능성이 큽니다.", + "RME Babyface Pro라면 SPDIF/ADAT 대신 아날로그 마이크 입력 채널 이름을 선택해야 합니다.", + ].join("\n"), + ); + } + } + private describeSink(): string { if (process.platform === "win32") { return this.options.config.LOCAL_AUDIO_SINK ?? "system-default";