From 5d636e8619d64a5901aee9354cc6f21a4f484106 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Thu, 30 Apr 2026 02:57:49 +0900 Subject: [PATCH] fix: handle missing ffmpeg on windows and add devices alias --- package.json | 4 ++- src/audio/ffmpeg-path.ts | 59 ++++++++++++++++++++++++++++++++ src/audio/local-voice-session.ts | 8 ++--- src/local-main.ts | 25 ++++++-------- src/services/elevenlabs-tts.ts | 4 +-- 5 files changed, 76 insertions(+), 24 deletions(-) create mode 100644 src/audio/ffmpeg-path.ts diff --git a/package.json b/package.json index d0e214c..c59611c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "bun src/index.ts discord", "start:discord": "bun src/index.ts discord", "start:local": "bun src/index.ts local", + "devices": "bun src/index.ts local-devices", "audio:devices": "bun src/index.ts local-devices", "check": "tsc --noEmit", "build": "tsc -p tsconfig.json" @@ -32,6 +33,7 @@ "typescript": "^6.0.3" }, "trustedDependencies": [ - "onnxruntime-node" + "onnxruntime-node", + "ffmpeg-static" ] } diff --git a/src/audio/ffmpeg-path.ts b/src/audio/ffmpeg-path.ts new file mode 100644 index 0000000..1480d34 --- /dev/null +++ b/src/audio/ffmpeg-path.ts @@ -0,0 +1,59 @@ +import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import process from "node:process"; + +import ffmpegStatic from "ffmpeg-static"; + +function firstExisting(paths: Array): string | null { + for (const candidate of paths) { + if (candidate && existsSync(candidate)) { + return candidate; + } + } + return null; +} + +function findOnPath(): string | null { + const locator = process.platform === "win32" ? "where" : "which"; + const binaryName = process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg"; + const result = spawnSync(locator, [binaryName], { + encoding: "utf8", + }); + + if (result.status !== 0) { + return null; + } + + const match = result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0 && existsSync(line)); + + return match ?? null; +} + +export function resolveFfmpegPath(): string | null { + const staticPath = ffmpegStatic as unknown as string | null; + return firstExisting([ + process.env.FFMPEG_PATH, + process.env.FFMPEG_BIN, + staticPath, + findOnPath(), + ]); +} + +export function requireFfmpegPath(): string { + const resolved = resolveFfmpegPath(); + if (resolved) { + return resolved; + } + + throw new Error( + [ + "ffmpeg를 찾지 못했습니다.", + "1. `bun install` 재실행", + "2. 안 되면 `bun pm trust ffmpeg-static` 후 다시 `bun install`", + "3. 또는 시스템 ffmpeg를 설치해서 PATH에 추가", + ].join("\n"), + ); +} diff --git a/src/audio/local-voice-session.ts b/src/audio/local-voice-session.ts index ddb5dd4..47c8148 100644 --- a/src/audio/local-voice-session.ts +++ b/src/audio/local-voice-session.ts @@ -5,11 +5,11 @@ 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"; import { Logger } from "../logger.js"; +import { requireFfmpegPath } from "./ffmpeg-path.js"; import { takeFrame, int16ArrayToFloat32, float32ToPcm16Buffer } from "./pcm.js"; import { ConversationMemory, type UserUtterance } from "../services/conversation.js"; import { ElevenLabsSttService } from "../services/elevenlabs-stt.js"; @@ -460,11 +460,7 @@ export class LocalVoiceSession { } private getFfmpegPath(): string { - const ffmpegPath = ffmpegStatic as unknown as string | null; - if (!ffmpegPath) { - throw new Error("ffmpeg-static 경로를 찾지 못했습니다."); - } - return ffmpegPath; + return requireFfmpegPath(); } private describeSink(): string { diff --git a/src/local-main.ts b/src/local-main.ts index 782c1b9..124e57f 100644 --- a/src/local-main.ts +++ b/src/local-main.ts @@ -1,29 +1,20 @@ import { spawn } from "node:child_process"; import process from "node:process"; -import ffmpegStatic from "ffmpeg-static"; - import type { AssistantRuntimeConfig } from "./config.js"; import { Logger } from "./logger.js"; import { LocalVoiceSession } from "./audio/local-voice-session.js"; +import { requireFfmpegPath } from "./audio/ffmpeg-path.js"; import { ElevenLabsSttService } from "./services/elevenlabs-stt.js"; import { ElevenLabsTtsService } from "./services/elevenlabs-tts.js"; import { OllamaLlmService } from "./services/ollama-llm.js"; -function resolveFfmpegPath(): string { - const ffmpegPath = ffmpegStatic as unknown as string | null; - if (!ffmpegPath) { - throw new Error("ffmpeg-static 경로를 찾지 못했습니다."); - } - return ffmpegPath; -} - export async function printLocalAudioDevices(): Promise { if (process.platform === "win32") { - const ffmpegPath = resolveFfmpegPath(); + const ffmpegPath = requireFfmpegPath(); console.log("\n=== ffmpeg dshow audio devices ==="); - await new Promise((resolve) => { + await new Promise((resolve, reject) => { const child = spawn( ffmpegPath, ["-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"], @@ -31,10 +22,14 @@ export async function printLocalAudioDevices(): Promise { stdio: ["ignore", "ignore", "inherit"], }, ); - child.on("exit", () => resolve()); - child.on("error", (error) => { - throw error; + child.on("exit", (code) => { + if (code === 0 || code === 1) { + resolve(); + return; + } + reject(new Error(`ffmpeg exited with code ${code ?? "null"}`)); }); + child.on("error", reject); }); console.log("\n위 목록의 오디오 장치 이름을 `LOCAL_AUDIO_SOURCE` 에 그대로 넣으면 됩니다."); diff --git a/src/services/elevenlabs-tts.ts b/src/services/elevenlabs-tts.ts index d22bed4..2b38975 100644 --- a/src/services/elevenlabs-tts.ts +++ b/src/services/elevenlabs-tts.ts @@ -1,9 +1,9 @@ import { Readable } from "node:stream"; -import ffmpegStatic from "ffmpeg-static"; import prism from "prism-media"; import type { AssistantRuntimeConfig } from "../config.js"; +import { resolveFfmpegPath } from "../audio/ffmpeg-path.js"; export interface PreparedSpeechAudio { stream: Readable; @@ -12,7 +12,7 @@ export interface PreparedSpeechAudio { export class ElevenLabsTtsService { constructor(private readonly config: AssistantRuntimeConfig) { - const resolvedFfmpegPath = ffmpegStatic as unknown as string | null; + const resolvedFfmpegPath = resolveFfmpegPath(); if (resolvedFfmpegPath && !process.env.FFMPEG_PATH) { process.env.FFMPEG_PATH = resolvedFfmpegPath; }