diff --git a/qwen3-tts-server/setup.sh b/qwen3-tts-server/setup.sh index d7ce37b..a696872 100755 --- a/qwen3-tts-server/setup.sh +++ b/qwen3-tts-server/setup.sh @@ -41,7 +41,7 @@ fi echo " Python: $py_version" # ── Virtual environment ────────────────────────────────────────────────────── -if [[ -d venv ]] && [[ ! -x venv/bin/python3 ]] && [[ ! -x venv/bin/python ]]; then +if [[ -d venv ]] && { [[ ! -x venv/bin/python3 ]] && [[ ! -x venv/bin/python ]]; } || [[ ! -e venv/bin/pip ]]; then warn "Existing virtual environment looks stale — recreating it." rm -rf venv fi diff --git a/src/audio.js b/src/audio.js index e245337..aea26e9 100644 --- a/src/audio.js +++ b/src/audio.js @@ -237,7 +237,9 @@ function getPlaybackCommand(platform, volume, cachePath) { return { cmd: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", cachePath] }; } if (platform === "linux") { - return { cmd: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", cachePath] }; + try { execSync("command -v ffplay", { stdio: "ignore" }); return { cmd: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", cachePath] }; } catch {} + try { execSync("command -v paplay", { stdio: "ignore" }); return { cmd: "paplay", args: [cachePath] }; } catch {} + try { execSync("command -v pw-play", { stdio: "ignore" }); return { cmd: "pw-play", args: [cachePath] }; } catch {} } return null; } @@ -344,6 +346,35 @@ function downloadChatterbox(phrase, cachePath, config, voicePath, ttsParams) { }); } +function downloadEspeak(phrase, cachePath) { + return new Promise((resolve) => { + const proc = spawn("espeak-ng", [ + "-w", cachePath, + "-s", "150", + phrase, + ], { stdio: ["ignore", "ignore", "ignore"] }); + proc.on("close", () => resolve()); + proc.on("error", () => resolve()); + }); +} + +function downloadPiper(phrase, cachePath, config) { + return new Promise((resolve) => { + const piperBin = config.piper_binary || process.env.PIPER_BIN || ""; + const piperModel = config.piper_model || ""; + if (!piperModel) return resolve(); + const args = ["-m", piperModel, "-f", cachePath]; + const extraArgs = config.piper_args || []; + const proc = spawn(piperBin || "piper", [...args, ...extraArgs], { + stdio: ["pipe", "ignore", "ignore"], + }); + proc.stdin.write(phrase); + proc.stdin.end(); + proc.on("close", () => resolve()); + proc.on("error", () => resolve()); + }); +} + function downloadQwen(phrase, cachePath, config, voiceId) { return new Promise((resolve) => { const qwenUrl = config.qwen_tts_url || "http://localhost:8100"; @@ -396,6 +427,12 @@ function downloadQwen(phrase, cachePath, config, voiceId) { } async function downloadToCache(phrase, cachePath, config, voicePath, ttsParams, refText) { + if (config.tts_backend === "espeak") { + return downloadEspeak(phrase, cachePath); + } + if (config.tts_backend === "piper") { + return downloadPiper(phrase, cachePath, config); + } if (config.tts_backend === "qwen") { const voiceId = await registerVoiceWithQwen(config, voicePath, refText); return downloadQwen(phrase, cachePath, config, voiceId); diff --git a/src/llm.js b/src/llm.js index d3d1df5..7b67dc3 100644 --- a/src/llm.js +++ b/src/llm.js @@ -190,6 +190,7 @@ function generatePhraseLocal(context, config, style, llmTemperature, examples) { const model = local.model || "default"; const maxTokens = local.max_tokens || 50; const timeout = local.timeout || 15000; + const apiKey = local.api_key || config.llm_api_key || process.env.ANTHROPIC_AUTH_TOKEN || process.env.API_KEY || ""; const messages = [ { role: "system", content: buildSystemPrompt(style, "status-report", examples) }, @@ -213,14 +214,17 @@ function generatePhraseLocal(context, config, style, llmTemperature, examples) { const isHttps = url.protocol === "https:"; const reqFn = isHttps ? httpsRequest : httpRequest; + const headers = { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + }; + if (apiKey) headers.Authorization = `Bearer ${apiKey}`; + const req = reqFn( url, { method: "POST", - headers: { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(payload), - }, + headers, timeout, }, (res) => { diff --git a/src/voxlert.js b/src/voxlert.js index 00a7dd1..4a5db2d 100755 --- a/src/voxlert.js +++ b/src/voxlert.js @@ -7,7 +7,7 @@ */ import { resolvePrefix, DEFAULT_PREFIX } from "./prefix.js"; -import { appendFileSync, mkdirSync } from "fs"; +import { appendFileSync, mkdirSync, readFileSync, writeFileSync, existsSync } from "fs"; import { hostname } from "os"; import { execSync } from "child_process"; import { loadConfig, EVENT_MAP, CONTEXTUAL_EVENTS, FALLBACK_PHRASES } from "./config.js"; @@ -15,9 +15,12 @@ import { extractContext, generatePhrase } from "./llm.js"; import { speakPhrase } from "./audio.js"; import { showOverlay } from "./overlay.js"; import { loadPack } from "./packs.js"; +import { join } from "path"; import { STATE_DIR, LOG_FILE, HOOK_DEBUG_LOG } from "./paths.js"; import { appendLog } from "./activity-log.js"; +const TASK_START_FILE = join(STATE_DIR, "task-start.json"); + function debugLog(msg, data) { try { mkdirSync(STATE_DIR, { recursive: true }); @@ -43,6 +46,31 @@ function logFallback(eventName, reason, detail) { } } +function getTaskStartTime() { + try { + if (existsSync(TASK_START_FILE)) { + const data = JSON.parse(readFileSync(TASK_START_FILE, "utf-8")); + return data.timestamp || 0; + } + } catch {} + return 0; +} + +function setTaskStartTime() { + try { + mkdirSync(STATE_DIR, { recursive: true }); + writeFileSync(TASK_START_FILE, JSON.stringify({ timestamp: Date.now() })); + } catch {} +} + +function shouldAnnounceStop(config) { + const minDuration = (config.min_task_duration_ms ?? 30000); + const start = getTaskStartTime(); + if (!start) return true; // no start recorded, allow + const elapsed = Date.now() - start; + return elapsed >= minDuration; +} + /** * Auto-detect tmux pane context from the environment. * Returns an object with { pane_id, session_name, window_index } when @@ -102,6 +130,19 @@ export async function processHookEvent(eventData) { return; } + // Track task start time on user prompt submit (always, regardless of category filter) + if (eventName === "UserPromptSubmit") { + setTaskStartTime(); + debugLog("processHookEvent task start recorded", { source }); + } + + // Skip short events for all categories (always, regardless of category filter) + if (!shouldAnnounceStop(config)) { + const elapsed = Date.now() - getTaskStartTime(); + debugLog("processHookEvent skip: short task", { source, eventName, elapsed_ms: elapsed }); + return; + } + // Check source-specific overrides (config.sources..enabled / .categories) const sourceOverride = (config.sources || {})[source] || {}; if (sourceOverride.enabled === false) {