Warn when Windows local input is silent
This commit is contained in:
@@ -33,6 +33,7 @@ export class LocalVoiceSession {
|
|||||||
private readonly memory: ConversationMemory;
|
private readonly memory: ConversationMemory;
|
||||||
private readonly queue: SpeechJob[] = [];
|
private readonly queue: SpeechJob[] = [];
|
||||||
private readonly pendingSamples: number[] = [];
|
private readonly pendingSamples: number[] = [];
|
||||||
|
private readonly silenceThreshold = 900;
|
||||||
|
|
||||||
private vad: RealTimeVAD | null = null;
|
private vad: RealTimeVAD | null = null;
|
||||||
private recorder: ChildProcessByStdio<null, Readable, Readable> | null = null;
|
private recorder: ChildProcessByStdio<null, Readable, Readable> | null = null;
|
||||||
@@ -42,6 +43,12 @@ export class LocalVoiceSession {
|
|||||||
private processing = Promise.resolve();
|
private processing = Promise.resolve();
|
||||||
private draining = false;
|
private draining = false;
|
||||||
private destroyed = 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) {
|
constructor(private readonly options: LocalVoiceSessionOptions) {
|
||||||
this.memory = new ConversationMemory(options.config.MAX_CONVERSATION_TURNS);
|
this.memory = new ConversationMemory(options.config.MAX_CONVERSATION_TURNS);
|
||||||
@@ -69,6 +76,11 @@ export class LocalVoiceSession {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.recorder = this.spawnRecorder();
|
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.recorder.stdout.on("data", (chunk: Buffer) => {
|
||||||
this.pushPcm16Chunk(chunk);
|
this.pushPcm16Chunk(chunk);
|
||||||
});
|
});
|
||||||
@@ -83,12 +95,21 @@ export class LocalVoiceSession {
|
|||||||
this.options.logger.warn("pw-record exited unexpectedly", { code, signal });
|
this.options.logger.warn("pw-record exited unexpectedly", { code, signal });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.inputWatchdog = setInterval(() => {
|
||||||
|
this.reportInputHealth();
|
||||||
|
}, 3_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async destroy(): Promise<void> {
|
async destroy(): Promise<void> {
|
||||||
this.destroyed = true;
|
this.destroyed = true;
|
||||||
this.interruptPlayback("local-shutdown");
|
this.interruptPlayback("local-shutdown");
|
||||||
|
|
||||||
|
if (this.inputWatchdog) {
|
||||||
|
clearInterval(this.inputWatchdog);
|
||||||
|
this.inputWatchdog = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.recorder && !this.recorder.killed) {
|
if (this.recorder && !this.recorder.killed) {
|
||||||
this.recorder.kill("SIGTERM");
|
this.recorder.kill("SIGTERM");
|
||||||
await once(this.recorder, "exit").catch(() => null);
|
await once(this.recorder, "exit").catch(() => null);
|
||||||
@@ -195,8 +216,20 @@ export class LocalVoiceSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.lastPcmChunkAt = Date.now();
|
||||||
|
let peak = 0;
|
||||||
|
|
||||||
for (let offset = 0; offset + 1 < chunk.length; offset += 2) {
|
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) {
|
while (true) {
|
||||||
@@ -216,6 +249,7 @@ export class LocalVoiceSession {
|
|||||||
|
|
||||||
private async handleSpeechEnd(audio: Float32Array): Promise<void> {
|
private async handleSpeechEnd(audio: Float32Array): Promise<void> {
|
||||||
if (audio.length < 16000 * 0.25) {
|
if (audio.length < 16000 * 0.25) {
|
||||||
|
this.options.logger.debug("Ignored short local speech segment", { samples: audio.length });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +268,7 @@ export class LocalVoiceSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!transcript || transcript.trim().length === 0) {
|
if (!transcript || transcript.trim().length === 0) {
|
||||||
|
this.options.logger.info("Local STT returned empty transcript");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,6 +498,39 @@ export class LocalVoiceSession {
|
|||||||
return requireFfmpegPath();
|
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 {
|
private describeSink(): string {
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
return this.options.config.LOCAL_AUDIO_SINK ?? "system-default";
|
return this.options.config.LOCAL_AUDIO_SINK ?? "system-default";
|
||||||
|
|||||||
Reference in New Issue
Block a user