feat(tts): add pocket-tts provider for local CPU-based TTS

Adds support for Pocket TTS (https://github.com/kyutai-labs/pocket-tts) as a new TTS provider.

Features:
- Local, offline TTS with no API key required
- ~200ms latency, runs on CPU
- 8 built-in voices + voice cloning via reference audio
- Auto-start server option (spawns pocket-tts serve)
- 60s cooldown after startup failure to prevent loops
- Doctor check for misconfigurations

Config:
  messages.tts.provider: 'pocket'
  messages.tts.pocket.enabled: true (default)
  messages.tts.pocket.baseUrl: 'http://localhost:8000' (default)
  messages.tts.pocket.voice: 'alba' (default)
  messages.tts.pocket.autoStart: false (default)

Output format is WAV (uncompressed). Falls back to other providers
if server is not running and autoStart is disabled.
This commit is contained in:
Lauren Rosenberg 2026-01-26 17:12:19 +00:00
parent b623557a2e
commit 408094d036
7 changed files with 902 additions and 3 deletions

View File

@ -0,0 +1,262 @@
---
summary: "Local CPU-based TTS with Pocket TTS"
read_when:
- You want local text-to-speech without cloud APIs
- You need free, offline TTS
- You want voice cloning capabilities
---
# Pocket TTS
[Pocket TTS](https://github.com/kyutai-labs/pocket-tts) is a local, CPU-only text-to-speech engine from Kyutai Labs. It runs entirely on your machine with no API keys required.
**Highlights:**
- ~100M parameter model, runs on CPU
- ~200ms latency to first audio chunk
- 6× realtime on MacBook Air M4
- Voice cloning via reference audio file
- 8 built-in voices
- Fully offline after initial model download (~400MB)
## Quick start
1. Install Pocket TTS:
```bash
# Using pip
pip install pocket-tts
# Or using uv (faster)
uv pip install pocket-tts
```
2. Start the server:
```bash
pocket-tts serve --voice alba
```
3. Configure Clawdbot:
```json5
{
messages: {
tts: {
provider: "pocket",
pocket: {
baseUrl: "http://localhost:8000", // default
voice: "alba" // default
}
}
}
}
```
4. Enable TTS:
```
/tts always
```
## Built-in voices
Pocket TTS includes 8 built-in voices (Les Misérables characters):
| Voice | Description |
|-------|-------------|
| `alba` | Default voice |
| `marius` | Male voice |
| `javert` | Male voice |
| `jean` | Male voice |
| `fantine` | Female voice |
| `cosette` | Female voice |
| `eponine` | Female voice |
| `azelma` | Female voice |
## Voice cloning
Clone any voice by providing a reference audio file:
```bash
# Start server with custom voice
pocket-tts serve --voice /path/to/your-voice.wav
```
Or use a HuggingFace voice:
```json5
{
messages: {
tts: {
pocket: {
voice: "hf://kyutai/tts-voices/custom-voice.wav"
}
}
}
}
```
## Configuration
### Full config options
```json5
{
messages: {
tts: {
provider: "pocket", // Use pocket as primary provider
pocket: {
enabled: true, // Enable/disable pocket (default: true)
baseUrl: "http://localhost:8000", // Server URL (also used for auto-start binding)
voice: "alba", // Voice name or URL
autoStart: false // Auto-start server if not running (default: false)
}
}
}
}
```
### Auto-start mode
Clawdbot can automatically start `pocket-tts serve` when it's not running:
```json5
{
messages: {
tts: {
provider: "pocket",
pocket: {
baseUrl: "http://localhost:8000", // Host/port derived from this URL
autoStart: true, // Spawn server automatically
voice: "alba" // Voice to use when starting
}
}
}
}
```
**How it works:**
1. Clawdbot checks `/health` endpoint
2. If server is down and `autoStart: true`, spawns `pocket-tts serve`
3. Host and port are derived from `baseUrl` (e.g., `http://localhost:9000``--host localhost --port 9000`)
4. Waits up to 30s for server to become healthy (model loading)
5. Server is stopped automatically when Clawdbot exits
**Note:** First request may be slow (~10-30s) while the model loads. Subsequent requests are fast (~200ms).
### Environment variables
Pocket TTS doesn't require API keys:
```bash
# Manual start (recommended for production)
pocket-tts serve --voice alba
# Or let Clawdbot auto-start via config
```
## Provider fallback
Clawdbot tries providers in order: **OpenAI → ElevenLabs → Edge → Pocket**
When Pocket TTS is configured but the server isn't running:
1. If `autoStart: true`, Clawdbot tries to start the server
2. If that fails (or `autoStart: false`), falls back to next provider
To check if the server is running:
```bash
curl http://localhost:8000/health
# Returns: {"status": "healthy"}
```
### Using Pocket as primary
To use Pocket first (before cloud providers):
```json5
{
messages: {
tts: {
provider: "pocket" // Try pocket first, fall back to others
}
}
}
```
## Comparison with other providers
| Provider | API Key | Latency | Cost | Offline | Output |
|----------|---------|---------|------|---------|--------|
| Pocket TTS | No | ~200ms | Free | Yes | WAV |
| Edge TTS | No | ~500ms | Free | No | MP3 |
| OpenAI | Yes | ~300ms | $0.015/1K chars | No | MP3/WAV |
| ElevenLabs | Yes | ~400ms | $0.30/1K chars | No | MP3 |
**Note:** Pocket TTS outputs WAV format (uncompressed). File sizes are ~5-10x larger than MP3 (~100KB vs ~15KB for short phrases). Most messaging platforms handle this fine.
**First run:** Downloads ~400MB model from HuggingFace on first use. After that, it's fully offline.
## Troubleshooting
### Server not running
If you see "pocket: server not running", start the server:
```bash
pocket-tts serve --voice alba
```
Or enable auto-start in config:
```json5
{ messages: { tts: { pocket: { autoStart: true } } } }
```
### pocket-tts not installed
If auto-start fails with "ENOENT" or "command not found":
```bash
# Install pocket-tts
pip install pocket-tts
# Verify installation
pocket-tts --help
```
### Wrong Python version
Pocket TTS requires Python 3.10+:
```bash
python3 --version
# Should be 3.10 or higher
```
### Model loading slow
First request takes 10-30s to load the model. This is normal.
Subsequent requests are fast (~200ms).
### Invalid voice error
Valid voices are:
- Built-in: `alba`, `marius`, `javert`, `jean`, `fantine`, `cosette`, `eponine`, `azelma`
- HuggingFace: `hf://kyutai/tts-voices/...`
- HTTP/HTTPS URLs
Local file paths are **not** supported via the API. Use `pocket-tts serve --voice /path/to/file.wav` instead.
### Missing dependencies
```bash
# Reinstall with all dependencies
pip install pocket-tts[audio]
```
## Links
- [Pocket TTS GitHub](https://github.com/kyutai-labs/pocket-tts)
- [Kyutai Labs](https://kyutai.org)
- [HuggingFace Model](https://huggingface.co/kyutai/pocket-tts-v1)

View File

@ -8,7 +8,7 @@ read_when:
# Text-to-speech (TTS)
Clawdbot can convert outbound replies into audio using ElevenLabs, OpenAI, or Edge TTS.
Clawdbot can convert outbound replies into audio using ElevenLabs, OpenAI, Edge TTS, or Pocket TTS.
It works anywhere Clawdbot can send audio; Telegram gets a round voice-note bubble.
## Supported services
@ -16,6 +16,7 @@ It works anywhere Clawdbot can send audio; Telegram gets a round voice-note bubb
- **ElevenLabs** (primary or fallback provider)
- **OpenAI** (primary or fallback provider; also used for summaries)
- **Edge TTS** (primary or fallback provider; uses `node-edge-tts`, default when no API keys)
- **Pocket TTS** (local CPU-based TTS; see [Pocket TTS docs](providers/pocket-tts.md))
### Edge TTS notes
@ -50,6 +51,7 @@ so that provider must also be authenticated if you enable summaries.
- [ElevenLabs Authentication](https://elevenlabs.io/docs/api-reference/authentication)
- [node-edge-tts](https://github.com/SchneeHertz/node-edge-tts)
- [Microsoft Speech output formats](https://learn.microsoft.com/azure/ai-services/speech-service/rest-text-to-speech#audio-outputs)
- [Pocket TTS](https://github.com/kyutai-labs/pocket-tts) (local CPU-based TTS)
## Is it enabled by default?

View File

@ -0,0 +1,90 @@
import type { ClawdbotConfig } from "../config/config.js";
import { runExec } from "../process/exec.js";
import { note } from "../terminal/note.js";
/**
* Check if pocket-tts CLI is installed and accessible.
*/
async function isPocketTtsInstalled(): Promise<boolean> {
try {
await runExec("pocket-tts", ["--version"], { timeoutMs: 5_000 });
return true;
} catch {
return false;
}
}
/**
* Check if pocket-tts server is running at the given URL.
*/
async function isPocketServerHealthy(baseUrl: string): Promise<boolean> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
try {
const url = baseUrl.replace(/\/+$/, "") + "/health";
const response = await fetch(url, {
method: "GET",
signal: controller.signal,
});
return response.ok;
} finally {
clearTimeout(timeout);
}
} catch {
return false;
}
}
/**
* Note TTS configuration warnings.
* Called from doctor.ts.
*/
export async function noteTtsConfigWarnings(cfg: ClawdbotConfig): Promise<void> {
const ttsConfig = cfg.messages?.tts;
if (!ttsConfig) return;
const pocket = ttsConfig.pocket;
const isPocketProvider = ttsConfig.provider === "pocket";
const isPocketEnabled = pocket?.enabled !== false; // defaults to true
// Only check pocket if it's relevant
if (!isPocketProvider && !isPocketEnabled) return;
const warnings: string[] = [];
// Check if pocket-tts CLI is installed
const installed = await isPocketTtsInstalled();
if (!installed) {
if (isPocketProvider) {
warnings.push(
"pocket-tts is not installed but configured as the TTS provider.",
" Install: pip install pocket-tts",
" Or change provider: clawdbot config set messages.tts.provider openai",
);
} else if (pocket?.autoStart) {
warnings.push(
"pocket.autoStart is enabled but pocket-tts is not installed.",
" Install: pip install pocket-tts",
" Or disable: clawdbot config set messages.tts.pocket.autoStart false",
);
}
}
// Check if server is running (only if autoStart is off)
if (installed && isPocketProvider && !pocket?.autoStart) {
const baseUrl = pocket?.baseUrl || "http://localhost:8000";
const healthy = await isPocketServerHealthy(baseUrl);
if (!healthy) {
warnings.push(
`pocket-tts server is not running at ${baseUrl}.`,
" Start manually: pocket-tts serve --voice alba",
" Or enable auto-start: clawdbot config set messages.tts.pocket.autoStart true",
);
}
}
if (warnings.length > 0) {
note(warnings.join("\n"), "TTS");
}
}

View File

@ -48,6 +48,7 @@ import { maybeRepairUiProtocolFreshness } from "./doctor-ui.js";
import { maybeOfferUpdateBeforeDoctor } from "./doctor-update.js";
import { MEMORY_SYSTEM_PROMPT, shouldSuggestMemorySystem } from "./doctor-workspace.js";
import { noteWorkspaceStatus } from "./doctor-workspace-status.js";
import { noteTtsConfigWarnings } from "./doctor-tts.js";
import { applyWizardMetadata, printWizardHeader, randomToken } from "./onboard-helpers.js";
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
@ -252,6 +253,7 @@ export async function doctorCommand(
}
noteWorkspaceStatus(cfg);
await noteTtsConfigWarnings(cfg);
const { healthOk } = await checkGatewayHealth({
runtime,

View File

@ -1,4 +1,4 @@
export type TtsProvider = "elevenlabs" | "openai" | "edge";
export type TtsProvider = "elevenlabs" | "openai" | "edge" | "pocket";
export type TtsMode = "final" | "all";
@ -73,6 +73,17 @@ export type TtsConfig = {
proxy?: string;
timeoutMs?: number;
};
/** Pocket TTS (local CPU-based TTS) configuration. */
pocket?: {
/** Explicitly allow Pocket TTS usage (no API key required, but requires pocket-tts serve running). */
enabled?: boolean;
/** Base URL for pocket-tts serve (default: http://localhost:8000). Used for both API calls and auto-start binding. */
baseUrl?: string;
/** Voice name or URL (built-in: alba, marius, javert, jean, fantine, cosette, eponine, azelma; or hf:// URL). */
voice?: string;
/** Auto-start pocket-tts serve if not running (default: false). Requires pocket-tts to be installed. */
autoStart?: boolean;
};
/** Optional path for local TTS user preferences JSON. */
prefsPath?: string;
/** Hard cap for text sent to TTS (chars). */

View File

@ -560,4 +560,207 @@ describe("tts", () => {
process.env.CLAWDBOT_TTS_PREFS = prevPrefs;
});
});
describe("pocket TTS", () => {
const {
POCKET_VOICES,
checkPocketHealth,
normalizePocketBaseUrl,
parsePocketBaseUrl,
isValidPocketVoice,
} = _test;
describe("POCKET_VOICES", () => {
it("contains all built-in pocket-tts voices", () => {
expect(POCKET_VOICES).toContain("alba");
expect(POCKET_VOICES).toContain("marius");
expect(POCKET_VOICES).toContain("javert");
expect(POCKET_VOICES).toContain("jean");
expect(POCKET_VOICES).toContain("fantine");
expect(POCKET_VOICES).toContain("cosette");
expect(POCKET_VOICES).toContain("eponine");
expect(POCKET_VOICES).toContain("azelma");
expect(POCKET_VOICES.length).toBe(8);
});
});
describe("normalizePocketBaseUrl", () => {
it("returns default URL for empty input", () => {
expect(normalizePocketBaseUrl("")).toBe("http://localhost:8000");
expect(normalizePocketBaseUrl(" ")).toBe("http://localhost:8000");
});
it("strips trailing slashes", () => {
expect(normalizePocketBaseUrl("http://localhost:8000/")).toBe("http://localhost:8000");
expect(normalizePocketBaseUrl("http://localhost:8000///")).toBe("http://localhost:8000");
});
it("preserves valid URLs without trailing slash", () => {
expect(normalizePocketBaseUrl("http://localhost:8000")).toBe("http://localhost:8000");
expect(normalizePocketBaseUrl("http://192.168.1.100:9000")).toBe(
"http://192.168.1.100:9000",
);
});
});
describe("parsePocketBaseUrl", () => {
it("extracts host and port from URL", () => {
expect(parsePocketBaseUrl("http://localhost:8000")).toEqual({
host: "localhost",
port: 8000,
});
expect(parsePocketBaseUrl("http://192.168.1.100:9000")).toEqual({
host: "192.168.1.100",
port: 9000,
});
expect(parsePocketBaseUrl("http://myserver:3000/")).toEqual({
host: "myserver",
port: 3000,
});
});
it("uses defaults for missing port", () => {
expect(parsePocketBaseUrl("http://localhost")).toEqual({ host: "localhost", port: 8000 });
});
it("handles invalid URLs gracefully", () => {
expect(parsePocketBaseUrl("not-a-url")).toEqual({ host: "localhost", port: 8000 });
expect(parsePocketBaseUrl("")).toEqual({ host: "localhost", port: 8000 });
});
});
describe("isValidPocketVoice", () => {
it("accepts built-in voices", () => {
expect(isValidPocketVoice("alba")).toBe(true);
expect(isValidPocketVoice("marius")).toBe(true);
expect(isValidPocketVoice("cosette")).toBe(true);
});
it("accepts URL-based voices", () => {
expect(isValidPocketVoice("http://example.com/voice.wav")).toBe(true);
expect(isValidPocketVoice("https://example.com/voice.wav")).toBe(true);
expect(isValidPocketVoice("hf://kyutai/tts-voices/custom.wav")).toBe(true);
});
it("rejects unknown voice names", () => {
expect(isValidPocketVoice("unknown_voice")).toBe(false);
expect(isValidPocketVoice("my_custom_voice")).toBe(false);
expect(isValidPocketVoice("/path/to/local/file.wav")).toBe(false);
});
});
describe("checkPocketHealth", () => {
it("returns false when server is not reachable", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async () => {
throw new Error("Connection refused");
}) as unknown as typeof fetch;
const result = await checkPocketHealth("http://localhost:9999", 1000);
expect(result).toBe(false);
globalThis.fetch = originalFetch;
});
it("returns true when server responds with ok", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async () => ({
ok: true,
json: async () => ({ status: "healthy" }),
})) as unknown as typeof fetch;
const result = await checkPocketHealth("http://localhost:8000", 1000);
expect(result).toBe(true);
globalThis.fetch = originalFetch;
});
it("returns false when server responds with error status", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async () => ({
ok: false,
status: 500,
})) as unknown as typeof fetch;
const result = await checkPocketHealth("http://localhost:8000", 1000);
expect(result).toBe(false);
globalThis.fetch = originalFetch;
});
});
describe("resolveTtsConfig pocket defaults", () => {
it("resolves pocket config with defaults", () => {
const config = resolveTtsConfig({});
expect(config.pocket.enabled).toBe(true);
expect(config.pocket.baseUrl).toBe("http://localhost:8000");
expect(config.pocket.voice).toBe("alba");
expect(config.pocket.autoStart).toBe(false);
});
it("respects custom pocket config", () => {
const config = resolveTtsConfig({
messages: {
tts: {
pocket: {
enabled: false,
baseUrl: "http://myserver:9000",
voice: "marius",
autoStart: true,
},
},
},
});
expect(config.pocket.enabled).toBe(false);
expect(config.pocket.baseUrl).toBe("http://myserver:9000");
expect(config.pocket.voice).toBe("marius");
expect(config.pocket.autoStart).toBe(true);
});
});
describe("auto-start configuration", () => {
it("autoStart defaults to false for safety", () => {
const config = resolveTtsConfig({
messages: {
tts: {
provider: "pocket",
},
},
});
expect(config.pocket.autoStart).toBe(false);
});
it("can enable autoStart via config", () => {
const config = resolveTtsConfig({
messages: {
tts: {
pocket: {
autoStart: true,
},
},
},
});
expect(config.pocket.autoStart).toBe(true);
});
it("derives host/port from baseUrl for auto-start", () => {
// This tests that baseUrl is the single source of truth
const config = resolveTtsConfig({
messages: {
tts: {
pocket: {
baseUrl: "http://192.168.1.50:9999",
autoStart: true,
},
},
},
});
expect(config.pocket.baseUrl).toBe("http://192.168.1.50:9999");
// parsePocketBaseUrl will extract host/port from baseUrl
const { host, port } = parsePocketBaseUrl(config.pocket.baseUrl);
expect(host).toBe("192.168.1.50");
expect(port).toBe(9999);
});
});
});
});

View File

@ -52,6 +52,19 @@ const DEFAULT_EDGE_VOICE = "en-US-MichelleNeural";
const DEFAULT_EDGE_LANG = "en-US";
const DEFAULT_EDGE_OUTPUT_FORMAT = "audio-24khz-48kbitrate-mono-mp3";
const DEFAULT_POCKET_BASE_URL = "http://localhost:8000";
const DEFAULT_POCKET_VOICE = "alba";
const POCKET_VOICES = [
"alba",
"marius",
"javert",
"jean",
"fantine",
"cosette",
"eponine",
"azelma",
] as const;
const DEFAULT_ELEVENLABS_VOICE_SETTINGS = {
stability: 0.5,
similarityBoost: 0.75,
@ -124,6 +137,12 @@ export type ResolvedTtsConfig = {
proxy?: string;
timeoutMs?: number;
};
pocket: {
enabled: boolean;
baseUrl: string;
voice: string;
autoStart: boolean;
};
prefsPath?: string;
maxTextLength: number;
timeoutMs: number;
@ -165,6 +184,9 @@ type TtsDirectiveOverrides = {
languageCode?: string;
voiceSettings?: Partial<ResolvedTtsConfig["elevenlabs"]["voiceSettings"]>;
};
pocket?: {
voice?: string;
};
};
type TtsDirectiveParseResult = {
@ -296,6 +318,12 @@ export function resolveTtsConfig(cfg: ClawdbotConfig): ResolvedTtsConfig {
proxy: raw.edge?.proxy?.trim() || undefined,
timeoutMs: raw.edge?.timeoutMs,
},
pocket: {
enabled: raw.pocket?.enabled ?? true,
baseUrl: raw.pocket?.baseUrl?.trim() || DEFAULT_POCKET_BASE_URL,
voice: raw.pocket?.voice?.trim() || DEFAULT_POCKET_VOICE,
autoStart: raw.pocket?.autoStart ?? false,
},
prefsPath: raw.prefsPath,
maxTextLength: raw.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH,
timeoutMs: raw.timeoutMs ?? DEFAULT_TIMEOUT_MS,
@ -477,7 +505,7 @@ export function resolveTtsApiKey(
return undefined;
}
export const TTS_PROVIDERS = ["openai", "elevenlabs", "edge"] as const;
export const TTS_PROVIDERS = ["openai", "elevenlabs", "edge", "pocket"] as const;
export function resolveTtsProviderOrder(primary: TtsProvider): TtsProvider[] {
return [primary, ...TTS_PROVIDERS.filter((provider) => provider !== primary)];
@ -485,6 +513,7 @@ export function resolveTtsProviderOrder(primary: TtsProvider): TtsProvider[] {
export function isTtsProviderConfigured(config: ResolvedTtsConfig, provider: TtsProvider): boolean {
if (provider === "edge") return config.edge.enabled;
if (provider === "pocket") return config.pocket.enabled;
return Boolean(resolveTtsApiKey(config, provider));
}
@ -1068,6 +1097,237 @@ async function edgeTTS(params: {
await tts.ttsPromise(text, outputPath);
}
function normalizePocketBaseUrl(baseUrl: string): string {
const trimmed = baseUrl.trim();
if (!trimmed) return DEFAULT_POCKET_BASE_URL;
return trimmed.replace(/\/+$/, "");
}
function parsePocketBaseUrl(baseUrl: string): { host: string; port: number } {
try {
const url = new URL(normalizePocketBaseUrl(baseUrl));
return {
host: url.hostname || "localhost",
port: url.port ? parseInt(url.port, 10) : 8000,
};
} catch {
return { host: "localhost", port: 8000 };
}
}
function isValidPocketVoice(voice: string): boolean {
if (!voice) return false;
// Built-in voices
if (POCKET_VOICES.includes(voice as (typeof POCKET_VOICES)[number])) return true;
// URL-based voices (http://, https://, hf://)
if (voice.startsWith("http://") || voice.startsWith("https://") || voice.startsWith("hf://")) {
return true;
}
return false;
}
function formatPocketVoiceWarning(voice: string): string {
return `Pocket TTS voice "${voice}" may not be valid. Valid options: ${POCKET_VOICES.join(", ")}, or hf:// / http:// URLs`;
}
async function pocketTTS(params: {
text: string;
baseUrl: string;
voice: string;
timeoutMs: number;
}): Promise<Buffer> {
const { text, baseUrl, voice, timeoutMs } = params;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const url = new URL(`${normalizePocketBaseUrl(baseUrl)}/tts`);
// Build form data
const formData = new FormData();
formData.append("text", text);
// Always send voice_url - let server validate/reject invalid voices
// This ensures explicit errors rather than silent fallback to server default
if (voice) {
formData.append("voice_url", voice);
}
const response = await fetch(url.toString(), {
method: "POST",
body: formData,
signal: controller.signal,
});
if (!response.ok) {
let errorDetail = "";
try {
const errorText = await response.text();
// Try to parse JSON error response from FastAPI
const parsed = JSON.parse(errorText);
errorDetail = parsed.detail || errorText;
} catch {
// If not JSON, use raw text
}
throw new Error(
`Pocket TTS API error (${response.status})${errorDetail ? `: ${errorDetail}` : ""}`,
);
}
return Buffer.from(await response.arrayBuffer());
} finally {
clearTimeout(timeout);
}
}
async function checkPocketHealth(baseUrl: string, timeoutMs: number): Promise<boolean> {
const url = `${normalizePocketBaseUrl(baseUrl)}/health`;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Math.min(timeoutMs, 5000));
try {
const response = await fetch(url, {
method: "GET",
signal: controller.signal,
});
return response.ok;
} finally {
clearTimeout(timeout);
}
} catch (err) {
// Only log on explicit check, not during polling (too noisy)
// logVerbose(`TTS: pocket health check failed: ${err instanceof Error ? err.message : err}`);
return false;
}
}
// Pocket TTS subprocess management
let pocketProcess: import("node:child_process").ChildProcess | null = null;
let pocketStartupPromise: Promise<boolean> | null = null;
let pocketLastFailure: number = 0;
const POCKET_STARTUP_COOLDOWN_MS = 60000; // 1 minute cooldown after failure
async function startPocketServer(config: ResolvedTtsConfig["pocket"]): Promise<boolean> {
// If already running, return true
if (pocketProcess) {
return true;
}
// If startup is in progress, wait for it (fixes race condition)
if (pocketStartupPromise) {
return pocketStartupPromise;
}
// Cooldown after failure to prevent repeated spawn attempts
if (pocketLastFailure && Date.now() - pocketLastFailure < POCKET_STARTUP_COOLDOWN_MS) {
const remainingSec = Math.ceil(
(POCKET_STARTUP_COOLDOWN_MS - (Date.now() - pocketLastFailure)) / 1000,
);
logVerbose(`TTS: pocket-tts startup on cooldown (${remainingSec}s remaining)`);
return false;
}
// Start new startup process
pocketStartupPromise = (async () => {
try {
const { spawn } = await import("node:child_process");
const { host, port } = parsePocketBaseUrl(config.baseUrl);
const args = ["serve", "--host", host, "--port", String(port)];
// Add voice if specified
if (config.voice) {
args.push("--voice", config.voice);
}
logVerbose(`TTS: Starting pocket-tts server: pocket-tts ${args.join(" ")}`);
pocketProcess = spawn("pocket-tts", args, {
stdio: ["ignore", "ignore", "pipe"], // stdin: ignore, stdout: ignore (prevent buffer hang), stderr: pipe
detached: false,
});
let spawnError: Error | null = null;
pocketProcess.on("error", (err) => {
spawnError = err;
pocketProcess = null;
});
pocketProcess.on("exit", (code, signal) => {
logVerbose(`TTS: pocket-tts exited (code=${code}, signal=${signal})`);
pocketProcess = null;
});
// Capture stderr for debugging
pocketProcess.stderr?.on("data", (data: Buffer) => {
const msg = data.toString().trim();
if (msg) logVerbose(`TTS: pocket-tts: ${msg}`);
});
// Wait for server to become healthy (up to 30s for model loading)
const maxWait = 30000;
const pollInterval = 500;
const startTime = Date.now();
while (Date.now() - startTime < maxWait) {
await new Promise((r) => setTimeout(r, pollInterval));
// Check for spawn error (e.g., pocket-tts not installed)
if (spawnError) {
const err = spawnError as Error; // Type narrowing doesn't work across async callbacks
const hint = err.message.includes("ENOENT")
? " (is pocket-tts installed? Run: pip install pocket-tts)"
: "";
logVerbose(`TTS: pocket-tts spawn failed: ${err.message}${hint}`);
pocketLastFailure = Date.now();
return false;
}
if (!pocketProcess) {
// Process died during startup
pocketLastFailure = Date.now();
return false;
}
const healthy = await checkPocketHealth(config.baseUrl, 2000);
if (healthy) {
logVerbose(`TTS: pocket-tts server ready after ${Date.now() - startTime}ms`);
pocketLastFailure = 0; // Reset on success
return true;
}
}
logVerbose("TTS: pocket-tts server failed to become healthy within 30s");
stopPocketServer();
pocketLastFailure = Date.now();
return false;
} catch (err) {
logVerbose(`TTS: Failed to start pocket-tts: ${err instanceof Error ? err.message : err}`);
pocketLastFailure = Date.now();
return false;
} finally {
pocketStartupPromise = null;
}
})();
return pocketStartupPromise;
}
function stopPocketServer(): void {
if (pocketProcess) {
logVerbose("TTS: Stopping pocket-tts server");
pocketProcess.kill("SIGTERM");
pocketProcess = null;
}
}
// Clean up on process exit (don't call process.exit - let Clawdbot handle graceful shutdown)
process.on("exit", stopPocketServer);
process.on("SIGINT", stopPocketServer);
process.on("SIGTERM", stopPocketServer);
export async function textToSpeech(params: {
text: string;
cfg: ClawdbotConfig;
@ -1165,6 +1425,67 @@ export async function textToSpeech(params: {
};
}
if (provider === "pocket") {
if (!config.pocket.enabled) {
lastError = "pocket: disabled";
continue;
}
// Check if pocket-tts server is running
let isHealthy = await checkPocketHealth(config.pocket.baseUrl, config.timeoutMs);
// Try auto-starting if configured and not healthy
if (!isHealthy && config.pocket.autoStart) {
logVerbose("TTS: pocket-tts not running, attempting auto-start...");
const started = await startPocketServer(config.pocket);
if (started) {
isHealthy = true;
}
}
if (!isHealthy) {
const hint = config.pocket.autoStart
? "auto-start failed"
: "run `pocket-tts serve` or set pocket.autoStart=true";
lastError = `pocket: server not running (${hint})`;
continue;
}
// Support voice override from model directives
const pocketVoice = params.overrides?.pocket?.voice ?? config.pocket.voice;
// Warn if voice looks invalid (but still try - server will give authoritative error)
if (pocketVoice && !isValidPocketVoice(pocketVoice)) {
logVerbose(`TTS: ${formatPocketVoiceWarning(pocketVoice)}`);
}
const audioBuffer = await pocketTTS({
text: params.text,
baseUrl: config.pocket.baseUrl,
voice: pocketVoice,
timeoutMs: config.timeoutMs,
});
const latencyMs = Date.now() - providerStart;
// Pocket TTS returns WAV format
const tempDir = mkdtempSync(path.join(tmpdir(), "tts-"));
const audioPath = path.join(tempDir, `voice-${Date.now()}.wav`);
writeFileSync(audioPath, audioBuffer);
scheduleCleanup(tempDir);
const voiceCompatible = isVoiceCompatibleAudio({ fileName: audioPath });
return {
success: true,
audioPath,
latencyMs,
provider,
outputFormat: "wav",
voiceCompatible,
};
}
const apiKey = resolveTtsApiKey(config, provider);
if (!apiKey) {
lastError = `No API key for ${provider}`;
@ -1463,9 +1784,17 @@ export const _test = {
isValidOpenAIModel,
OPENAI_TTS_MODELS,
OPENAI_TTS_VOICES,
POCKET_VOICES,
parseTtsDirectives,
resolveModelOverridePolicy,
summarizeText,
resolveOutputFormat,
resolveEdgeOutputFormat,
checkPocketHealth,
normalizePocketBaseUrl,
parsePocketBaseUrl,
isValidPocketVoice,
formatPocketVoiceWarning,
startPocketServer,
stopPocketServer,
};