Add separate STT and LLM test commands

This commit is contained in:
2026-05-03 00:44:26 +09:00
parent 48937c684b
commit 7e59013fa4
9 changed files with 274 additions and 22 deletions

View File

@@ -19,6 +19,10 @@ const envSchema = z.object({
.string()
.optional()
.transform((value) => value?.trim().toLowerCase() === "true"),
OLLAMA_BASE_URL: z.string().min(1).default("http://127.0.0.1:11434"),
OLLAMA_MODEL: z.string().min(1).default("qwen3:8b"),
OLLAMA_KEEP_ALIVE: z.string().min(1).default("5m"),
MAX_CONVERSATION_TURNS: z.coerce.number().int().min(1).max(20).default(6),
WHISPER_MODEL: z.string().min(1).default("large-v3-turbo"),
WHISPER_LANGUAGE: z.string().min(1).default("ko"),
WHISPER_DEVICE: z.enum(["auto", "cuda", "cpu"]).default("auto"),

View File

@@ -1,14 +1,16 @@
import process from "node:process";
import { createInterface } from "node:readline";
import { loadConfig } from "./config.js";
import { Logger } from "./logger.js";
import { printAudioDevices, spawnLoopbackCapture } from "./audio/capture.js";
import { RealtimeSegmenter } from "./audio/realtime-segmenter.js";
import { FasterWhisperSttService } from "./services/faster-whisper-stt.js";
import { OllamaLlmService } from "./services/ollama-llm.js";
const mode = process.argv[2] ?? "loopback";
const mode = process.argv[2] ?? "test-stt";
async function runLoopback(): Promise<void> {
async function runSttTest(): Promise<void> {
const config = loadConfig();
const logger = new Logger(config.DEBUG ? config.LOG_LEVEL : "error");
const stt = new FasterWhisperSttService(config, logger);
@@ -104,7 +106,7 @@ async function runLoopback(): Promise<void> {
}
}
} catch (error) {
logger.warn("STT failed", error);
logger.error("STT failed", error);
} finally {
transcribing = false;
void runNext();
@@ -146,7 +148,11 @@ async function runLoopback(): Promise<void> {
},
onSpeechReady: (samples) => {
emittedSegmentCount += 1;
logger.info("Speech segment ready", { index: emittedSegmentCount, samples, ms: Math.round((samples / 16000) * 1000) });
logger.info("Speech segment ready", {
index: emittedSegmentCount,
samples,
ms: Math.round((samples / 16000) * 1000),
});
},
onSegment: (pcm16) => {
const index = nextSegmentIndex++;
@@ -188,7 +194,7 @@ async function runLoopback(): Promise<void> {
});
if (config.DEBUG) {
console.log("실시간 출력장치 STT를 시작합니다. Ctrl+C 로 종료합니다.");
console.log("실시간 출력장치 STT 테스트를 시작합니다. Ctrl+C 로 종료합니다.");
console.log(`source: ${config.AUDIO_SOURCE ?? "unset"}`);
console.log(`model: ${config.WHISPER_MODEL}`);
console.log(`language: ${config.WHISPER_LANGUAGE}`);
@@ -208,16 +214,76 @@ async function runLoopback(): Promise<void> {
}, 5000).unref();
}
async function runLlmCli(): Promise<void> {
const config = loadConfig();
const logger = new Logger(config.DEBUG ? config.LOG_LEVEL : "error");
const llm = new OllamaLlmService(config, logger);
await llm.warmup();
console.log(`LLM CLI 테스트를 시작합니다. model=${config.OLLAMA_MODEL}`);
console.log("/exit 로 종료, /reset 으로 대화 초기화");
const rl = createInterface({
input: process.stdin,
output: process.stdout,
prompt: "you> ",
});
rl.prompt();
rl.on("line", async (line) => {
const text = line.trim();
if (!text) {
rl.prompt();
return;
}
if (text === "/exit") {
rl.close();
return;
}
if (text === "/reset") {
llm.resetConversation();
console.log("assistant> 대화 문맥을 초기화했습니다.");
rl.prompt();
return;
}
try {
const startedAt = Date.now();
const reply = await llm.generateReply(text);
logger.info("LLM latency", {
llm_ms: Date.now() - startedAt,
});
console.log(`assistant> ${reply}`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
}
rl.prompt();
});
rl.on("close", () => {
process.exit(0);
});
}
async function main(): Promise<void> {
switch (mode) {
case "devices":
await printAudioDevices();
return;
case "loopback":
await runLoopback();
case "test-stt":
await runSttTest();
return;
case "test-llm":
await runLlmCli();
return;
default:
throw new Error(`알 수 없는 실행 모드입니다: ${mode}. 사용 가능: loopback, devices`);
throw new Error(`알 수 없는 실행 모드입니다: ${mode}. 사용 가능: test-stt, test-llm, devices`);
}
}

View File

@@ -0,0 +1,91 @@
import type { AppConfig } from "../config.js";
import type { Logger } from "../logger.js";
interface OllamaChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
interface OllamaChatResponse {
message?: {
content?: string;
};
}
const SYSTEM_PROMPT =
"너는 한국어로 짧고 자연스럽게 답하는 로컬 음성 비서다. 사용자의 말에 바로 답하고, 군더더기 없는 1~3문장으로 답해라.";
export class OllamaLlmService {
private history: OllamaChatMessage[] = [];
constructor(
private readonly config: AppConfig,
private readonly logger: Logger,
) {}
async warmup(): Promise<void> {
const reply = await this.chat(
[
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: "준비 상태 확인입니다. 한 단어로만 답하세요." },
],
);
this.logger.info("LLM warmup finished", { model: this.config.OLLAMA_MODEL, reply });
}
async generateReply(userText: string): Promise<string> {
const messages: OllamaChatMessage[] = [
{ role: "system", content: SYSTEM_PROMPT },
...this.history,
{ role: "user", content: userText },
];
const reply = await this.chat(messages);
this.history.push({ role: "user", content: userText });
this.history.push({ role: "assistant", content: reply });
this.trimHistory();
return reply;
}
resetConversation(): void {
this.history = [];
}
private trimHistory(): void {
const maxMessages = this.config.MAX_CONVERSATION_TURNS * 2;
if (this.history.length <= maxMessages) {
return;
}
this.history = this.history.slice(-maxMessages);
}
private async chat(messages: OllamaChatMessage[]): Promise<string> {
const response = await fetch(`${this.config.OLLAMA_BASE_URL}/api/chat`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
model: this.config.OLLAMA_MODEL,
messages,
stream: false,
think: false,
keep_alive: this.config.OLLAMA_KEEP_ALIVE,
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Ollama API ${response.status}: ${body}`);
}
const payload = (await response.json()) as OllamaChatResponse;
const content = payload.message?.content?.trim();
if (!content) {
throw new Error("Ollama 응답에 message.content 가 없습니다.");
}
return content;
}
}

38
src/setup-llm.ts Normal file
View File

@@ -0,0 +1,38 @@
import process from "node:process";
import { spawn } from "node:child_process";
import { loadConfig } from "./config.js";
async function run(command: string, args: string[]): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
stdio: "inherit",
windowsHide: true,
shell: process.platform === "win32",
});
child.on("exit", (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "null"}`));
});
child.on("error", reject);
});
}
export async function setupLlm(): Promise<void> {
const config = loadConfig();
console.log(`Ollama 모델 준비: ${config.OLLAMA_MODEL}`);
await run("ollama", ["pull", config.OLLAMA_MODEL]);
console.log("Ollama LLM 환경 준비 완료");
}
if (import.meta.main) {
void setupLlm().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}

View File

@@ -24,7 +24,7 @@ async function run(command: string, args: string[], cwd: string): Promise<void>
});
}
async function main(): Promise<void> {
export async function setupSttPython(): Promise<void> {
const config = loadConfig();
const python = await resolveBasePythonCommand(config);
const venvRoot = path.resolve(process.cwd(), config.LOCAL_AI_VENV_PATH);
@@ -47,7 +47,9 @@ async function main(): Promise<void> {
console.log("Python STT 환경 준비 완료");
}
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
if (import.meta.main) {
void setupSttPython().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}

16
src/setup.ts Normal file
View File

@@ -0,0 +1,16 @@
import process from "node:process";
import { setupLlm } from "./setup-llm.js";
import { setupSttPython } from "./setup-python.js";
async function main(): Promise<void> {
await setupSttPython();
await setupLlm();
}
if (import.meta.main) {
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}