fix: handle missing ffmpeg on windows and add devices alias

This commit is contained in:
2026-04-30 02:57:49 +09:00
parent 24aa58fc42
commit 5d636e8619
5 changed files with 76 additions and 24 deletions

View File

@@ -8,6 +8,7 @@
"start": "bun src/index.ts discord", "start": "bun src/index.ts discord",
"start:discord": "bun src/index.ts discord", "start:discord": "bun src/index.ts discord",
"start:local": "bun src/index.ts local", "start:local": "bun src/index.ts local",
"devices": "bun src/index.ts local-devices",
"audio:devices": "bun src/index.ts local-devices", "audio:devices": "bun src/index.ts local-devices",
"check": "tsc --noEmit", "check": "tsc --noEmit",
"build": "tsc -p tsconfig.json" "build": "tsc -p tsconfig.json"
@@ -32,6 +33,7 @@
"typescript": "^6.0.3" "typescript": "^6.0.3"
}, },
"trustedDependencies": [ "trustedDependencies": [
"onnxruntime-node" "onnxruntime-node",
"ffmpeg-static"
] ]
} }

59
src/audio/ffmpeg-path.ts Normal file
View File

@@ -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 | undefined>): 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"),
);
}

View File

@@ -5,11 +5,11 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import type { Readable, Writable } from "node:stream"; import type { Readable, Writable } from "node:stream";
import ffmpegStatic from "ffmpeg-static";
import { RealTimeVAD } from "avr-vad"; import { RealTimeVAD } from "avr-vad";
import type { AssistantRuntimeConfig } from "../config.js"; import type { AssistantRuntimeConfig } from "../config.js";
import { Logger } from "../logger.js"; import { Logger } from "../logger.js";
import { requireFfmpegPath } from "./ffmpeg-path.js";
import { takeFrame, int16ArrayToFloat32, float32ToPcm16Buffer } from "./pcm.js"; import { takeFrame, int16ArrayToFloat32, float32ToPcm16Buffer } from "./pcm.js";
import { ConversationMemory, type UserUtterance } from "../services/conversation.js"; import { ConversationMemory, type UserUtterance } from "../services/conversation.js";
import { ElevenLabsSttService } from "../services/elevenlabs-stt.js"; import { ElevenLabsSttService } from "../services/elevenlabs-stt.js";
@@ -460,11 +460,7 @@ export class LocalVoiceSession {
} }
private getFfmpegPath(): string { private getFfmpegPath(): string {
const ffmpegPath = ffmpegStatic as unknown as string | null; return requireFfmpegPath();
if (!ffmpegPath) {
throw new Error("ffmpeg-static 경로를 찾지 못했습니다.");
}
return ffmpegPath;
} }
private describeSink(): string { private describeSink(): string {

View File

@@ -1,29 +1,20 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import process from "node:process"; import process from "node:process";
import ffmpegStatic from "ffmpeg-static";
import type { AssistantRuntimeConfig } from "./config.js"; import type { AssistantRuntimeConfig } from "./config.js";
import { Logger } from "./logger.js"; import { Logger } from "./logger.js";
import { LocalVoiceSession } from "./audio/local-voice-session.js"; import { LocalVoiceSession } from "./audio/local-voice-session.js";
import { requireFfmpegPath } from "./audio/ffmpeg-path.js";
import { ElevenLabsSttService } from "./services/elevenlabs-stt.js"; import { ElevenLabsSttService } from "./services/elevenlabs-stt.js";
import { ElevenLabsTtsService } from "./services/elevenlabs-tts.js"; import { ElevenLabsTtsService } from "./services/elevenlabs-tts.js";
import { OllamaLlmService } from "./services/ollama-llm.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<void> { export async function printLocalAudioDevices(): Promise<void> {
if (process.platform === "win32") { if (process.platform === "win32") {
const ffmpegPath = resolveFfmpegPath(); const ffmpegPath = requireFfmpegPath();
console.log("\n=== ffmpeg dshow audio devices ==="); console.log("\n=== ffmpeg dshow audio devices ===");
await new Promise<void>((resolve) => { await new Promise<void>((resolve, reject) => {
const child = spawn( const child = spawn(
ffmpegPath, ffmpegPath,
["-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"], ["-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"],
@@ -31,10 +22,14 @@ export async function printLocalAudioDevices(): Promise<void> {
stdio: ["ignore", "ignore", "inherit"], stdio: ["ignore", "ignore", "inherit"],
}, },
); );
child.on("exit", () => resolve()); child.on("exit", (code) => {
child.on("error", (error) => { if (code === 0 || code === 1) {
throw error; resolve();
return;
}
reject(new Error(`ffmpeg exited with code ${code ?? "null"}`));
}); });
child.on("error", reject);
}); });
console.log("\n위 목록의 오디오 장치 이름을 `LOCAL_AUDIO_SOURCE` 에 그대로 넣으면 됩니다."); console.log("\n위 목록의 오디오 장치 이름을 `LOCAL_AUDIO_SOURCE` 에 그대로 넣으면 됩니다.");

View File

@@ -1,9 +1,9 @@
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import ffmpegStatic from "ffmpeg-static";
import prism from "prism-media"; import prism from "prism-media";
import type { AssistantRuntimeConfig } from "../config.js"; import type { AssistantRuntimeConfig } from "../config.js";
import { resolveFfmpegPath } from "../audio/ffmpeg-path.js";
export interface PreparedSpeechAudio { export interface PreparedSpeechAudio {
stream: Readable; stream: Readable;
@@ -12,7 +12,7 @@ export interface PreparedSpeechAudio {
export class ElevenLabsTtsService { export class ElevenLabsTtsService {
constructor(private readonly config: AssistantRuntimeConfig) { constructor(private readonly config: AssistantRuntimeConfig) {
const resolvedFfmpegPath = ffmpegStatic as unknown as string | null; const resolvedFfmpegPath = resolveFfmpegPath();
if (resolvedFfmpegPath && !process.env.FFMPEG_PATH) { if (resolvedFfmpegPath && !process.env.FFMPEG_PATH) {
process.env.FFMPEG_PATH = resolvedFfmpegPath; process.env.FFMPEG_PATH = resolvedFfmpegPath;
} }