fix: support blank env values and windows local audio
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
||||
import { spawn, type ChildProcess, type ChildProcessByStdio } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Readable, Writable } from "node:stream";
|
||||
|
||||
import ffmpegStatic from "ffmpeg-static";
|
||||
import { RealTimeVAD } from "avr-vad";
|
||||
|
||||
import type { AssistantRuntimeConfig } from "../config.js";
|
||||
@@ -32,7 +36,7 @@ export class LocalVoiceSession {
|
||||
|
||||
private vad: RealTimeVAD | null = null;
|
||||
private recorder: ChildProcessByStdio<null, Readable, Readable> | null = null;
|
||||
private currentPlayer: ChildProcessByStdio<Writable, null, Readable> | null = null;
|
||||
private currentPlayer: ChildProcess | null = null;
|
||||
private currentAbortController: AbortController | null = null;
|
||||
private currentPlayback: PreparedSpeechAudio | null = null;
|
||||
private processing = Promise.resolve();
|
||||
@@ -114,14 +118,19 @@ export class LocalVoiceSession {
|
||||
statusSummary(): string {
|
||||
return [
|
||||
"모드: local",
|
||||
`플랫폼: ${process.platform}`,
|
||||
`입력 source: ${this.options.config.LOCAL_AUDIO_SOURCE ?? "default"}`,
|
||||
`출력 sink: ${this.options.config.LOCAL_AUDIO_SINK ?? "default"}`,
|
||||
`출력 sink: ${this.describeSink()}`,
|
||||
`대기열: ${this.queue.length}`,
|
||||
`최근 대화 턴: ${this.memory.recentTurns().length}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
private spawnRecorder(): ChildProcessByStdio<null, Readable, Readable> {
|
||||
if (process.platform === "win32") {
|
||||
return this.spawnWindowsRecorder();
|
||||
}
|
||||
|
||||
const args = [
|
||||
"--rate",
|
||||
"16000",
|
||||
@@ -147,6 +156,40 @@ export class LocalVoiceSession {
|
||||
});
|
||||
}
|
||||
|
||||
private spawnWindowsRecorder(): ChildProcessByStdio<null, Readable, Readable> {
|
||||
const ffmpegPath = this.getFfmpegPath();
|
||||
const sourceName = this.options.config.LOCAL_AUDIO_SOURCE;
|
||||
if (!sourceName) {
|
||||
throw new Error("Windows 로컬 모드는 LOCAL_AUDIO_SOURCE 설정이 필요합니다. `bun run audio:devices` 로 이름을 확인해 주세요.");
|
||||
}
|
||||
|
||||
const args = [
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"warning",
|
||||
"-f",
|
||||
"dshow",
|
||||
"-i",
|
||||
`audio=${sourceName}`,
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-f",
|
||||
"s16le",
|
||||
"pipe:1",
|
||||
];
|
||||
|
||||
this.options.logger.info("Starting local recorder", {
|
||||
source: sourceName,
|
||||
backend: "ffmpeg-dshow",
|
||||
});
|
||||
|
||||
return spawn(ffmpegPath, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
private pushPcm16Chunk(chunk: Buffer): void {
|
||||
if (this.destroyed || !this.vad) {
|
||||
return;
|
||||
@@ -284,6 +327,11 @@ export class LocalVoiceSession {
|
||||
}
|
||||
|
||||
private async playToSink(playback: PreparedSpeechAudio, signal: AbortSignal): Promise<void> {
|
||||
if (process.platform === "win32") {
|
||||
await this.playToWindowsDefaultSink(playback, signal);
|
||||
return;
|
||||
}
|
||||
|
||||
const args = [
|
||||
"--rate",
|
||||
"48000",
|
||||
@@ -336,4 +384,120 @@ export class LocalVoiceSession {
|
||||
throw new Error(`pw-play exited with code=${code ?? "null"} signal=${playSignal ?? "null"}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async playToWindowsDefaultSink(playback: PreparedSpeechAudio, signal: AbortSignal): Promise<void> {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
playback.stream.on("data", (chunk: Buffer) => {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
});
|
||||
playback.stream.once("end", resolve);
|
||||
playback.stream.once("error", reject);
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
playback.stream.destroy();
|
||||
reject(new Error("playback aborted"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}).catch((error) => {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pcm = Buffer.concat(chunks);
|
||||
const wav = createWaveFileBuffer(pcm, 48000, 2, 16);
|
||||
const tempPath = path.join(os.tmpdir(), `realtime-voice-bot-${Date.now()}.wav`);
|
||||
await fs.writeFile(tempPath, wav);
|
||||
|
||||
const psScript = [
|
||||
"Add-Type -AssemblyName System;",
|
||||
`$player = New-Object System.Media.SoundPlayer('${tempPath.replace(/'/g, "''")}');`,
|
||||
"$player.PlaySync();",
|
||||
].join(" ");
|
||||
|
||||
const player = spawn("powershell", ["-NoProfile", "-Command", psScript], {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
this.currentPlayer = player;
|
||||
|
||||
player.stderr.on("data", (chunk: Buffer) => {
|
||||
const text = chunk.toString().trim();
|
||||
if (text.length > 0) {
|
||||
this.options.logger.debug("[powershell-player]", text);
|
||||
}
|
||||
});
|
||||
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
if (!player.killed) {
|
||||
player.kill("SIGKILL");
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
const [code, playSignal] = (await once(player, "exit")) as [number | null, NodeJS.Signals | null];
|
||||
this.currentPlayer = null;
|
||||
await fs.unlink(tempPath).catch(() => null);
|
||||
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
throw new Error(`powershell playback exited with code=${code ?? "null"} signal=${playSignal ?? "null"}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getFfmpegPath(): string {
|
||||
const ffmpegPath = ffmpegStatic as unknown as string | null;
|
||||
if (!ffmpegPath) {
|
||||
throw new Error("ffmpeg-static 경로를 찾지 못했습니다.");
|
||||
}
|
||||
return ffmpegPath;
|
||||
}
|
||||
|
||||
private describeSink(): string {
|
||||
if (process.platform === "win32") {
|
||||
return this.options.config.LOCAL_AUDIO_SINK ?? "system-default";
|
||||
}
|
||||
return this.options.config.LOCAL_AUDIO_SINK ?? "default";
|
||||
}
|
||||
}
|
||||
|
||||
function createWaveFileBuffer(
|
||||
pcm: Buffer,
|
||||
sampleRate: number,
|
||||
channels: number,
|
||||
bitsPerSample: number,
|
||||
): Buffer {
|
||||
const header = Buffer.alloc(44);
|
||||
const byteRate = sampleRate * channels * (bitsPerSample / 8);
|
||||
const blockAlign = channels * (bitsPerSample / 8);
|
||||
|
||||
header.write("RIFF", 0, 4, "ascii");
|
||||
header.writeUInt32LE(36 + pcm.length, 4);
|
||||
header.write("WAVE", 8, 4, "ascii");
|
||||
header.write("fmt ", 12, 4, "ascii");
|
||||
header.writeUInt32LE(16, 16);
|
||||
header.writeUInt16LE(1, 20);
|
||||
header.writeUInt16LE(channels, 22);
|
||||
header.writeUInt32LE(sampleRate, 24);
|
||||
header.writeUInt32LE(byteRate, 28);
|
||||
header.writeUInt16LE(blockAlign, 32);
|
||||
header.writeUInt16LE(bitsPerSample, 34);
|
||||
header.write("data", 36, 4, "ascii");
|
||||
header.writeUInt32LE(pcm.length, 40);
|
||||
|
||||
return Buffer.concat([header, pcm]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user