Add full STT LLM TTS test mode
This commit is contained in:
36
src/index.ts
36
src/index.ts
@@ -11,12 +11,12 @@ import { OllamaLlmService } from "./services/ollama-llm.js";
|
||||
|
||||
const mode = process.argv[2] ?? "test-stt";
|
||||
|
||||
async function runSttTest(enableLlm: boolean): Promise<void> {
|
||||
async function runSttTest(options: { enableLlm: boolean; enableTts: boolean }): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const logger = new Logger(config.DEBUG ? config.LOG_LEVEL : "error");
|
||||
const stt = new FasterWhisperSttService(config, logger);
|
||||
const llm = enableLlm ? new OllamaLlmService(config, logger) : null;
|
||||
let tts = enableLlm && config.TTS_ENABLED ? new MeloTtsService(config, logger) : null;
|
||||
const llm = options.enableLlm ? new OllamaLlmService(config, logger) : null;
|
||||
let tts = options.enableTts && config.TTS_ENABLED ? new MeloTtsService(config, logger) : null;
|
||||
let capture = null as ReturnType<typeof spawnLoopbackCapture> | null;
|
||||
let shuttingDown: Promise<void> | null = null;
|
||||
let suppressCapture = false;
|
||||
@@ -47,6 +47,11 @@ async function runSttTest(enableLlm: boolean): Promise<void> {
|
||||
await stt.destroy().catch((destroyError) => {
|
||||
logger.warn("STT destroy failed", destroyError);
|
||||
});
|
||||
if (tts) {
|
||||
await tts.destroy().catch((destroyError) => {
|
||||
logger.warn("TTS destroy failed", destroyError);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
await shuttingDown;
|
||||
@@ -70,6 +75,9 @@ async function runSttTest(enableLlm: boolean): Promise<void> {
|
||||
capture.kill("SIGKILL");
|
||||
}
|
||||
void stt.destroy();
|
||||
if (tts) {
|
||||
void tts.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
console.log("STT 준비중...");
|
||||
@@ -282,14 +290,23 @@ async function runSttTest(enableLlm: boolean): Promise<void> {
|
||||
});
|
||||
|
||||
if (config.DEBUG) {
|
||||
console.log(enableLlm ? "실시간 출력장치 STT+LLM 테스트를 시작합니다. Ctrl+C 로 종료합니다." : "실시간 출력장치 STT 테스트를 시작합니다. Ctrl+C 로 종료합니다.");
|
||||
if (options.enableLlm && options.enableTts) {
|
||||
console.log("실시간 출력장치 STT+LLM+TTS 테스트를 시작합니다. Ctrl+C 로 종료합니다.");
|
||||
} else if (options.enableLlm) {
|
||||
console.log("실시간 출력장치 STT+LLM 테스트를 시작합니다. Ctrl+C 로 종료합니다.");
|
||||
} else {
|
||||
console.log("실시간 출력장치 STT 테스트를 시작합니다. Ctrl+C 로 종료합니다.");
|
||||
}
|
||||
console.log(`source: ${config.AUDIO_SOURCE ?? "unset"}`);
|
||||
console.log(`model: ${config.WHISPER_MODEL}`);
|
||||
console.log(`language: ${config.WHISPER_LANGUAGE}`);
|
||||
console.log(`beam: ${config.WHISPER_BEAM_SIZE}`);
|
||||
if (enableLlm) {
|
||||
if (options.enableLlm) {
|
||||
console.log(`llm: ${config.OLLAMA_MODEL}`);
|
||||
}
|
||||
if (options.enableTts) {
|
||||
console.log(`tts: ${config.TTS_IMAGE}`);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
@@ -385,10 +402,13 @@ async function main(): Promise<void> {
|
||||
await printAudioDevices();
|
||||
return;
|
||||
case "test-stt":
|
||||
await runSttTest(false);
|
||||
await runSttTest({ enableLlm: false, enableTts: false });
|
||||
return;
|
||||
case "test-sttllm":
|
||||
await runSttTest(true);
|
||||
await runSttTest({ enableLlm: true, enableTts: false });
|
||||
return;
|
||||
case "test-all":
|
||||
await runSttTest({ enableLlm: true, enableTts: true });
|
||||
return;
|
||||
case "test-llm":
|
||||
await runLlmCli();
|
||||
@@ -397,7 +417,7 @@ async function main(): Promise<void> {
|
||||
await runTtsTest();
|
||||
return;
|
||||
default:
|
||||
throw new Error(`알 수 없는 실행 모드입니다: ${mode}. 사용 가능: test-stt, test-sttllm, test-llm, test-tts, devices`);
|
||||
throw new Error(`알 수 없는 실행 모드입니다: ${mode}. 사용 가능: test-stt, test-sttllm, test-all, test-llm, test-tts, devices`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { once } from "node:events";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
import type { AppConfig } from "../config.js";
|
||||
import { resolveDockerCommand } from "../docker-runtime.js";
|
||||
@@ -41,19 +43,80 @@ async function run(command: string, args: string[], stdio: "ignore" | "inherit"
|
||||
});
|
||||
}
|
||||
|
||||
interface RpcSuccess<T> {
|
||||
id: string;
|
||||
result: T;
|
||||
}
|
||||
|
||||
interface RpcFailure {
|
||||
id: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
type RpcResponse<T> = RpcSuccess<T> | RpcFailure;
|
||||
|
||||
function isFailure<T>(value: RpcResponse<T>): value is RpcFailure {
|
||||
return "error" in value;
|
||||
}
|
||||
|
||||
interface TtsPingResult {
|
||||
language: string;
|
||||
speaker: string;
|
||||
speaker_id: number;
|
||||
device: string;
|
||||
speed: number;
|
||||
sdp_ratio: number;
|
||||
noise_scale: number;
|
||||
noise_scale_w: number;
|
||||
speaker_count: number;
|
||||
}
|
||||
|
||||
export class MeloTtsService {
|
||||
private processRef: ChildProcessWithoutNullStreams | null = null;
|
||||
private shuttingDown = false;
|
||||
private warmedUp = false;
|
||||
private readonly pending = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}
|
||||
>();
|
||||
private nextId = 1;
|
||||
|
||||
constructor(
|
||||
private readonly config: AppConfig,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async warmup(): Promise<void> {
|
||||
if (this.warmedUp) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mkdir(path.resolve(process.cwd(), this.config.TTS_CACHE_DIR), { recursive: true });
|
||||
await mkdir(path.resolve(process.cwd(), this.config.TTS_OUTPUT_DIR), { recursive: true });
|
||||
const docker = await resolveDockerCommand(this.config);
|
||||
|
||||
await run(docker, ["--version"]);
|
||||
await run(docker, ["image", "inspect", this.config.TTS_IMAGE]);
|
||||
|
||||
await this.start();
|
||||
const result = await this.request<TtsPingResult>("ping", {});
|
||||
this.logger.info("TTS worker ready", result);
|
||||
|
||||
const warmupFileName = `warmup-${randomUUID()}.wav`;
|
||||
const warmupHostPath = path.resolve(process.cwd(), this.config.TTS_OUTPUT_DIR, warmupFileName);
|
||||
try {
|
||||
await this.request("synthesize", {
|
||||
text: "안녕하세요. 로컬 티티에스 준비 테스트입니다.",
|
||||
output_path: `/work/output/${warmupFileName}`,
|
||||
});
|
||||
} finally {
|
||||
await rm(warmupHostPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
this.warmedUp = true;
|
||||
}
|
||||
|
||||
async speak(text: string): Promise<void> {
|
||||
@@ -66,62 +129,16 @@ export class MeloTtsService {
|
||||
const targetPath = path.resolve(process.cwd(), this.config.TTS_OUTPUT_DIR, fileName);
|
||||
|
||||
try {
|
||||
await this.synthesizeToFile(trimmed, targetPath);
|
||||
await this.synthesizeToFile(trimmed, targetPath, fileName);
|
||||
await playWavFile(targetPath, this.config.TTS_PLAYBACK_RATE);
|
||||
} finally {
|
||||
await rm(targetPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async synthesizeToFile(text: string, targetPath: string): Promise<void> {
|
||||
async synthesizeToFile(text: string, targetPath: string, fileName?: string): Promise<void> {
|
||||
await this.warmup();
|
||||
|
||||
const outputDir = path.dirname(targetPath);
|
||||
const cacheDir = path.resolve(process.cwd(), this.config.TTS_CACHE_DIR);
|
||||
const fileName = path.basename(targetPath);
|
||||
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const args = [
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
`${outputDir}:/work/output`,
|
||||
"-v",
|
||||
`${cacheDir}:/cache`,
|
||||
"-e",
|
||||
"HF_HOME=/cache/huggingface",
|
||||
"-e",
|
||||
"HF_HUB_CACHE=/cache/huggingface/hub",
|
||||
"-e",
|
||||
"TRANSFORMERS_CACHE=/cache/transformers",
|
||||
];
|
||||
|
||||
if (this.config.TTS_DEVICE !== "cpu") {
|
||||
args.push("--gpus", "all");
|
||||
}
|
||||
|
||||
args.push(
|
||||
this.config.TTS_IMAGE,
|
||||
"--text",
|
||||
text,
|
||||
"--output",
|
||||
`/work/output/${fileName}`,
|
||||
"--language",
|
||||
this.config.TTS_LANGUAGE,
|
||||
"--speaker",
|
||||
this.config.TTS_SPEAKER,
|
||||
"--speed",
|
||||
String(this.config.TTS_SPEED),
|
||||
"--sdp-ratio",
|
||||
String(this.config.TTS_SDP_RATIO),
|
||||
"--noise-scale",
|
||||
String(this.config.TTS_NOISE_SCALE),
|
||||
"--noise-scale-w",
|
||||
String(this.config.TTS_NOISE_SCALE_W),
|
||||
"--device",
|
||||
this.config.TTS_DEVICE,
|
||||
);
|
||||
const resolvedFileName = fileName ?? path.basename(targetPath);
|
||||
|
||||
this.logger.info("Starting MeloTTS synthesis", {
|
||||
image: this.config.TTS_IMAGE,
|
||||
@@ -135,8 +152,45 @@ export class MeloTtsService {
|
||||
device: this.config.TTS_DEVICE,
|
||||
});
|
||||
|
||||
const docker = await resolveDockerCommand(this.config);
|
||||
await run(docker, args, "inherit");
|
||||
await this.request("synthesize", {
|
||||
text,
|
||||
output_path: `/work/output/${resolvedFileName}`,
|
||||
});
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
if (!this.processRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const child = this.processRef;
|
||||
this.shuttingDown = true;
|
||||
|
||||
try {
|
||||
child.stdin.end();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!child.killed && child.exitCode === null) {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
|
||||
const timedWait = Promise.race([
|
||||
once(child, "exit"),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1500)),
|
||||
]);
|
||||
|
||||
await timedWait;
|
||||
|
||||
if (child.exitCode === null && !child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
await once(child, "exit").catch(() => null);
|
||||
}
|
||||
|
||||
this.processRef = null;
|
||||
this.shuttingDown = false;
|
||||
this.warmedUp = false;
|
||||
}
|
||||
|
||||
private normalizeText(input: string): string {
|
||||
@@ -155,4 +209,156 @@ export class MeloTtsService {
|
||||
|
||||
return `${collapsed}.`;
|
||||
}
|
||||
|
||||
private async start(): Promise<void> {
|
||||
if (this.processRef) {
|
||||
return;
|
||||
}
|
||||
if (this.shuttingDown) {
|
||||
throw new Error("tts worker is shutting down");
|
||||
}
|
||||
|
||||
const docker = await resolveDockerCommand(this.config);
|
||||
const outputDir = path.resolve(process.cwd(), this.config.TTS_OUTPUT_DIR);
|
||||
const cacheDir = path.resolve(process.cwd(), this.config.TTS_CACHE_DIR);
|
||||
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
|
||||
const args = [
|
||||
"run",
|
||||
"--rm",
|
||||
"-i",
|
||||
"-v",
|
||||
`${outputDir}:/work/output`,
|
||||
"-v",
|
||||
`${cacheDir}:/cache`,
|
||||
"-e",
|
||||
"HF_HOME=/cache/huggingface",
|
||||
"-e",
|
||||
"HF_HUB_CACHE=/cache/huggingface/hub",
|
||||
"-e",
|
||||
"TRANSFORMERS_CACHE=/cache/transformers",
|
||||
"-e",
|
||||
`TTS_LANGUAGE=${this.config.TTS_LANGUAGE}`,
|
||||
"-e",
|
||||
`TTS_SPEAKER=${this.config.TTS_SPEAKER}`,
|
||||
"-e",
|
||||
`TTS_DEVICE=${this.config.TTS_DEVICE}`,
|
||||
"-e",
|
||||
`TTS_SPEED=${this.config.TTS_SPEED}`,
|
||||
"-e",
|
||||
`TTS_SDP_RATIO=${this.config.TTS_SDP_RATIO}`,
|
||||
"-e",
|
||||
`TTS_NOISE_SCALE=${this.config.TTS_NOISE_SCALE}`,
|
||||
"-e",
|
||||
`TTS_NOISE_SCALE_W=${this.config.TTS_NOISE_SCALE_W}`,
|
||||
"--entrypoint",
|
||||
"python",
|
||||
];
|
||||
|
||||
if (this.config.TTS_DEVICE !== "cpu") {
|
||||
args.push("--gpus", "all");
|
||||
}
|
||||
|
||||
args.push(
|
||||
this.config.TTS_IMAGE,
|
||||
"/opt/realtime-voice-bot/melo_tts_worker.py",
|
||||
);
|
||||
|
||||
const env = { ...process.env };
|
||||
if (path.isAbsolute(docker)) {
|
||||
const dockerBinDir = path.dirname(docker);
|
||||
const currentPath = env.PATH ?? env.Path ?? "";
|
||||
env.PATH = `${dockerBinDir}${path.delimiter}${currentPath}`;
|
||||
}
|
||||
|
||||
this.processRef = spawn(docker, args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
shell: process.platform === "win32" && !path.isAbsolute(docker),
|
||||
env,
|
||||
});
|
||||
|
||||
const rl = createInterface({
|
||||
input: this.processRef.stdout,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
rl.on("line", (line) => {
|
||||
this.handleStdoutLine(line);
|
||||
});
|
||||
|
||||
this.processRef.stderr.on("data", (chunk: Buffer) => {
|
||||
const text = chunk.toString().trim();
|
||||
if (text.length > 0) {
|
||||
this.logger.warn(`[melotts] ${text}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.processRef.stdin.on("error", (error) => {
|
||||
this.logger.debug("melotts stdin error", error);
|
||||
});
|
||||
|
||||
this.processRef.on("exit", (code, signal) => {
|
||||
const error = new Error(`melotts worker exited code=${code ?? "null"} signal=${signal ?? "null"}`);
|
||||
for (const entry of this.pending.values()) {
|
||||
entry.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.processRef = null;
|
||||
});
|
||||
}
|
||||
|
||||
private async request<T>(method: string, params: Record<string, unknown>): Promise<T> {
|
||||
await this.start();
|
||||
|
||||
if (!this.processRef) {
|
||||
throw new Error("melotts worker is not running");
|
||||
}
|
||||
|
||||
const id = String(this.nextId++);
|
||||
const payload = JSON.stringify({
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
});
|
||||
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(id, {
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
});
|
||||
});
|
||||
|
||||
this.processRef.stdin.write(`${payload}\n`);
|
||||
return await promise;
|
||||
}
|
||||
|
||||
private handleStdoutLine(line: string): void {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let message: RpcResponse<unknown>;
|
||||
try {
|
||||
message = JSON.parse(trimmed) as RpcResponse<unknown>;
|
||||
} catch (error) {
|
||||
this.logger.warn("melotts stdout parse failed", error);
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = this.pending.get(message.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pending.delete(message.id);
|
||||
if (isFailure(message)) {
|
||||
pending.reject(new Error(message.error));
|
||||
return;
|
||||
}
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,6 +378,7 @@ export class OllamaLlmService {
|
||||
"bun run devices",
|
||||
"bun run test:stt",
|
||||
"bun run test:sttllm",
|
||||
"bun run test:all",
|
||||
"bun run test:llm",
|
||||
"bun run test:tts -- \"안녕하세요\"",
|
||||
],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import process from "node:process";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
@@ -57,13 +57,12 @@ export async function setupTts(): Promise<void> {
|
||||
await run(docker, ["build", "-t", config.TTS_IMAGE, dockerContext]);
|
||||
|
||||
const tts = new MeloTtsService(config, logger);
|
||||
const warmupPath = path.join(outputDir, "warmup.wav");
|
||||
|
||||
console.log("MeloTTS 모델 워밍업...");
|
||||
try {
|
||||
await tts.synthesizeToFile("안녕하세요. 로컬 티티에스 준비 테스트입니다.", warmupPath);
|
||||
await tts.warmup();
|
||||
} finally {
|
||||
await rm(warmupPath, { force: true }).catch(() => undefined);
|
||||
await tts.destroy().catch(() => undefined);
|
||||
}
|
||||
|
||||
console.log("로컬 TTS 환경 준비 완료");
|
||||
|
||||
Reference in New Issue
Block a user