Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ For unauthenticated local endpoints (e.g. Ollama):
- `reasoningEffort` _(optional)_ - reasoning level for models that support it
- `chatTemplateKwargs` _(optional)_ - extra keyword arguments passed to the model's chat template (e.g. `{"enable_thinking": false}` for Qwen models to disable chain-of-thought)
- `retries` _(optional)_ - number of retry attempts for transient LLM failures
- `tmpDir` _(optional)_ - directory used for the temporary STT recording file (default `/tmp`)

### Logging

Expand Down
38 changes: 25 additions & 13 deletions lib/stt.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ let sttApiEndpoint = null;
let sttApiModel = null;
let sttApiKeyEnv = null;

const WAV_FILE = "/tmp/opencode-stt.wav";
const WAV_FILENAME = "opencode-stt.wav";
let tmpDir = "/tmp";

const MODELS_DIRS = [
path.join(os.homedir(), ".local", "share", "whisper-cpp"),
Expand Down Expand Up @@ -120,9 +121,10 @@ function startRecording(kv, toast, logger) {
return;
}

const wavFile = path.join(tmpDir, WAV_FILENAME);
forceKillSox(logger);
try {
fs.unlinkSync(WAV_FILE);
fs.unlinkSync(wavFile);
} catch {}

soxStderr = "";
Expand All @@ -132,7 +134,7 @@ function startRecording(kv, toast, logger) {

soxProc = spawn(
"sox",
[...inputArgs, "-r", "16000", "-c", "1", "-b", "16", WAV_FILE, "silence", "1", "0.1", "1%"],
[...inputArgs, "-r", "16000", "-c", "1", "-b", "16", wavFile, "silence", "1", "0.1", "1%"],
{
stdio: ["ignore", "ignore", "pipe"],
detached: false,
Expand Down Expand Up @@ -195,6 +197,7 @@ function getModelPath(kv) {
}

function transcribe(kv, logger) {
const wavFile = path.join(tmpDir, WAV_FILENAME);
const mp = getModelPath(kv);
logger?.log("STT", `Local transcription requested model=${mp}`, "debug");
if (!fs.existsSync(mp)) {
Expand All @@ -203,19 +206,19 @@ function transcribe(kv, logger) {
error: `Model not found: ${getModelName(kv)}. Download from huggingface.co/ggerganov/whisper.cpp`,
});
}
if (!fs.existsSync(WAV_FILE)) {
logger?.log("STT", `Recording file missing: ${WAV_FILE}`, "error");
if (!fs.existsSync(wavFile)) {
logger?.log("STT", `Recording file missing: ${wavFile}`, "error");
return Promise.resolve({ error: "No recording file - sox may have failed to capture audio" });
}
if (fs.statSync(WAV_FILE).size <= 44) {
logger?.log("STT", `Recording file empty: ${WAV_FILE}`, "warn");
if (fs.statSync(wavFile).size <= 44) {
logger?.log("STT", `Recording file empty: ${wavFile}`, "warn");
return Promise.resolve({ error: "Recording is empty - no audio captured" });
}

return new Promise((resolve) => {
let stdout = "";
let stderr = "";
const proc = spawn("whisper-cli", ["-m", mp, "-f", WAV_FILE, "-np", "-nt"], {
const proc = spawn("whisper-cli", ["-m", mp, "-f", wavFile, "-np", "-nt"], {
stdio: ["ignore", "pipe", "pipe"],
});
logger?.log("STT", `Started whisper-cli pid=${proc.pid}`, "debug");
Expand Down Expand Up @@ -330,20 +333,21 @@ async function transcribeApi(kv, logger) {
logger?.log("STT", "STT API transcription skipped: API not configured", "warn");
return { error: "STT API not configured" };
}
const wavFile = path.join(tmpDir, WAV_FILENAME);
const model = kv.get("stt.api.model") || sttApiModel;
logger?.log("STT", `STT API transcription requested model=${model}`, "debug");

if (!fs.existsSync(WAV_FILE)) {
logger?.log("STT", `Recording file missing: ${WAV_FILE}`, "error");
if (!fs.existsSync(wavFile)) {
logger?.log("STT", `Recording file missing: ${wavFile}`, "error");
return { error: "No recording file - sox may have failed to capture audio" };
}
if (fs.statSync(WAV_FILE).size <= 44) {
logger?.log("STT", `Recording file empty: ${WAV_FILE}`, "warn");
if (fs.statSync(wavFile).size <= 44) {
logger?.log("STT", `Recording file empty: ${wavFile}`, "warn");
return { error: "Recording is empty - no audio captured" };
}

try {
const audioBuffer = await fs.promises.readFile(WAV_FILE);
const audioBuffer = await fs.promises.readFile(wavFile);
const apiKey = sttApiKeyEnv ? process.env[sttApiKeyEnv] : null;
const useOpenRouterFormat = isOpenRouterEndpoint(sttApiEndpoint);

Expand Down Expand Up @@ -487,6 +491,14 @@ export function registerSTT(api, kv, complete, prompts, opts, logger) {
);
}

tmpDir = opts?.tmpDir || "/tmp";
try {
fs.mkdirSync(tmpDir, { recursive: true });
} catch (err) {
logger?.log("STT", `Failed to create tmpDir ${tmpDir}: ${err.message}`, "warn");
}
logger?.log("STT", `STT temp dir=${tmpDir}`, "debug");

return [
{
title: sttApiEndpoint ? "STT: record/transcribe (API)" : "STT: record/transcribe",
Expand Down
Loading