voicecc 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/.claude-plugin/plugin.json +6 -0
  2. package/README.md +48 -0
  3. package/bin/voicecc.js +39 -0
  4. package/dashboard/dist/assets/index-BXemFrMp.css +1 -0
  5. package/dashboard/dist/assets/index-dAYfRls7.js +11 -0
  6. package/dashboard/dist/audio-processor.js +126 -0
  7. package/dashboard/dist/index.html +13 -0
  8. package/dashboard/routes/auth.ts +119 -0
  9. package/dashboard/routes/browser-call.ts +87 -0
  10. package/dashboard/routes/claude-md.ts +50 -0
  11. package/dashboard/routes/conversations.ts +203 -0
  12. package/dashboard/routes/integrations.ts +154 -0
  13. package/dashboard/routes/mcp-servers.ts +198 -0
  14. package/dashboard/routes/settings.ts +64 -0
  15. package/dashboard/routes/tunnel.ts +66 -0
  16. package/dashboard/routes/twilio.ts +120 -0
  17. package/dashboard/routes/voice.ts +48 -0
  18. package/dashboard/routes/webrtc.ts +85 -0
  19. package/dashboard/server.ts +130 -0
  20. package/dashboard/tsconfig.json +13 -0
  21. package/init/CLAUDE.md +18 -0
  22. package/package.json +59 -0
  23. package/run.ts +68 -0
  24. package/scripts/postinstall.js +228 -0
  25. package/services/browser-call-manager.ts +106 -0
  26. package/services/device-pairing.ts +176 -0
  27. package/services/env.ts +88 -0
  28. package/services/tunnel.ts +204 -0
  29. package/services/twilio-manager.ts +126 -0
  30. package/sidecar/assets/startup.pcm +0 -0
  31. package/sidecar/audio-adapter.ts +60 -0
  32. package/sidecar/audio-capture.ts +220 -0
  33. package/sidecar/browser-audio-playback.test.ts +149 -0
  34. package/sidecar/browser-audio.ts +147 -0
  35. package/sidecar/browser-server.ts +331 -0
  36. package/sidecar/chime.test.ts +69 -0
  37. package/sidecar/chime.ts +54 -0
  38. package/sidecar/claude-session.ts +295 -0
  39. package/sidecar/endpointing.ts +163 -0
  40. package/sidecar/index.ts +83 -0
  41. package/sidecar/local-audio.ts +126 -0
  42. package/sidecar/mic-vpio +0 -0
  43. package/sidecar/mic-vpio.swift +484 -0
  44. package/sidecar/mock-tts-server-tagged.mjs +132 -0
  45. package/sidecar/narration.ts +204 -0
  46. package/sidecar/scripts/generate-startup-audio.py +79 -0
  47. package/sidecar/session-lock.ts +123 -0
  48. package/sidecar/sherpa-onnx-node.d.ts +4 -0
  49. package/sidecar/stt.ts +199 -0
  50. package/sidecar/tts-server.py +193 -0
  51. package/sidecar/tts.ts +481 -0
  52. package/sidecar/twilio-audio.ts +338 -0
  53. package/sidecar/twilio-server.ts +436 -0
  54. package/sidecar/types.ts +210 -0
  55. package/sidecar/vad.ts +101 -0
  56. package/sidecar/voice-loop-bugs.test.ts +522 -0
  57. package/sidecar/voice-session.ts +523 -0
  58. package/skills/voice/SKILL.md +26 -0
  59. package/tsconfig.json +22 -0
@@ -0,0 +1,85 @@
1
+ /**
2
+ * WebRTC device pairing API routes.
3
+ *
4
+ * Handles pairing code generation and device token validation:
5
+ * - POST /generate-code -- localhost-only, create a 6-digit pairing code
6
+ * - POST /pair -- validate a code and issue a device token
7
+ * - GET /validate -- check if a device token is valid
8
+ */
9
+
10
+ import { Hono } from "hono";
11
+ import {
12
+ generatePairingCode,
13
+ validateAndConsumeCode,
14
+ isValidDeviceToken,
15
+ isPairingCodeActive,
16
+ } from "../../services/device-pairing.js";
17
+
18
+ // ============================================================================
19
+ // ROUTES
20
+ // ============================================================================
21
+
22
+ /**
23
+ * Create Hono route group for WebRTC pairing operations.
24
+ *
25
+ * @returns Hono instance with generate-code, pair, and validate routes
26
+ */
27
+ export function webrtcRoutes(): Hono {
28
+ const app = new Hono();
29
+
30
+ /** Generate a pairing code (localhost only) */
31
+ app.post("/generate-code", (c) => {
32
+ const remoteAddr = c.req.header("x-forwarded-for") ?? "";
33
+ const isLocalhost = remoteAddr === "127.0.0.1" || remoteAddr === "::1" || remoteAddr === "::ffff:127.0.0.1" || remoteAddr === "";
34
+ console.log(`[webrtc] generate-code from ${remoteAddr || "(empty)"}, isLocalhost=${isLocalhost}`);
35
+
36
+ if (!isLocalhost) {
37
+ console.log("[webrtc] generate-code REJECTED: not localhost");
38
+ return c.json({ error: "Pairing codes can only be generated from localhost" }, 403);
39
+ }
40
+
41
+ const result = generatePairingCode();
42
+ console.log("[webrtc] generate-code OK, code:", result.code);
43
+ return c.json(result);
44
+ });
45
+
46
+ /** Validate a pairing code and issue a device token */
47
+ app.post("/pair", async (c) => {
48
+ const body = await c.req.json<{ code?: string }>();
49
+ console.log("[webrtc] pair request, code:", body.code);
50
+
51
+ if (!body.code || typeof body.code !== "string") {
52
+ console.log("[webrtc] pair REJECTED: missing code");
53
+ return c.json({ error: "Missing 'code' in request body" }, 400);
54
+ }
55
+
56
+ const userAgent = c.req.header("user-agent") ?? "unknown";
57
+ const result = validateAndConsumeCode(body.code, userAgent);
58
+
59
+ if (!result.ok) {
60
+ console.log("[webrtc] pair REJECTED:", result.error);
61
+ return c.json({ error: result.error }, 401);
62
+ }
63
+
64
+ console.log("[webrtc] pair OK, token issued");
65
+ return c.json({ token: result.token });
66
+ });
67
+
68
+ /** Check if a pairing code is still active (not yet consumed) */
69
+ app.get("/code-status", (c) => {
70
+ const code = c.req.query("code");
71
+ if (!code) return c.json({ error: "Missing code query param" }, 400);
72
+ return c.json({ active: isPairingCodeActive(code) });
73
+ });
74
+
75
+ /** Validate a device token from the Authorization header */
76
+ app.get("/validate", (c) => {
77
+ const authHeader = c.req.header("authorization") ?? "";
78
+ const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
79
+ const valid = isValidDeviceToken(token);
80
+ console.log("[webrtc] validate token=%s...%s valid=%s", token.slice(0, 6), token.slice(-4), valid);
81
+ return c.json({ valid });
82
+ });
83
+
84
+ return app;
85
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Dashboard Hono server -- serves the React frontend and mounts API routes.
3
+ *
4
+ * Thin wiring file that creates the Hono app and starts listening:
5
+ * - Mount all API route groups under /api/*
6
+ * - Serve the Vite build output as static files
7
+ * - SPA fallback for client-side routing
8
+ */
9
+
10
+ import { Hono } from "hono";
11
+ import { serve } from "@hono/node-server";
12
+ import { serveStatic } from "@hono/node-server/serve-static";
13
+ import { readFileSync } from "fs";
14
+ import { access } from "fs/promises";
15
+ import { join } from "path";
16
+ import { homedir } from "os";
17
+
18
+ import { claudeMdRoutes } from "./routes/claude-md.js";
19
+ import { conversationRoutes } from "./routes/conversations.js";
20
+ import { settingsRoutes } from "./routes/settings.js";
21
+ import { voiceRoutes } from "./routes/voice.js";
22
+ import { tunnelRoutes } from "./routes/tunnel.js";
23
+ import { twilioRoutes, setDashboardPort } from "./routes/twilio.js";
24
+ import { browserCallRoutes, setDashboardPort as setBrowserCallDashboardPort } from "./routes/browser-call.js";
25
+ import { webrtcRoutes } from "./routes/webrtc.js";
26
+ import { mcpServersRoutes } from "./routes/mcp-servers.js";
27
+ import { authRoutes } from "./routes/auth.js";
28
+ import { integrationsRoutes, setDashboardPort as setIntegrationsDashboardPort } from "./routes/integrations.js";
29
+ import { loadDeviceTokens } from "../services/device-pairing.js";
30
+
31
+ // ============================================================================
32
+ // CONSTANTS
33
+ // ============================================================================
34
+
35
+ const PORTS_TO_TRY = [3456, 3457, 3458, 3459, 3460];
36
+ const USER_CLAUDE_MD_PATH = join(homedir(), ".claude", "CLAUDE.md");
37
+
38
+ // ============================================================================
39
+ // MAIN HANDLERS
40
+ // ============================================================================
41
+
42
+ /**
43
+ * Create the Hono app with all route groups mounted.
44
+ *
45
+ * @returns Configured Hono app instance
46
+ */
47
+ function createApp(): Hono {
48
+ const app = new Hono();
49
+
50
+ // API route groups
51
+ app.route("/api/claude-md", claudeMdRoutes());
52
+ app.route("/api/conversations", conversationRoutes());
53
+ app.route("/api/settings", settingsRoutes());
54
+ app.route("/api/voice", voiceRoutes());
55
+ app.route("/api/tunnel", tunnelRoutes());
56
+ app.route("/api/twilio", twilioRoutes());
57
+ app.route("/api/browser-call", browserCallRoutes());
58
+ app.route("/api/webrtc", webrtcRoutes());
59
+ app.route("/api/mcp-servers", mcpServersRoutes());
60
+ app.route("/api/auth", authRoutes());
61
+ app.route("/api/integrations", integrationsRoutes());
62
+
63
+ // Status endpoint (user CLAUDE.md conflict check)
64
+ app.get("/api/status", async (c) => {
65
+ let hasUserClaudeMd = false;
66
+ try {
67
+ await access(USER_CLAUDE_MD_PATH);
68
+ hasUserClaudeMd = true;
69
+ } catch {
70
+ // File doesn't exist
71
+ }
72
+ return c.json({
73
+ userClaudeMdExists: hasUserClaudeMd,
74
+ userClaudeMdPath: USER_CLAUDE_MD_PATH,
75
+ });
76
+ });
77
+
78
+ // Serve Vite build output
79
+ app.get("*", serveStatic({ root: "./dashboard/dist" }));
80
+
81
+ // SPA fallback: serve index.html for unmatched routes (client-side routing)
82
+ app.get("*", (c) => {
83
+ try {
84
+ const html = readFileSync("./dashboard/dist/index.html", "utf-8");
85
+ return c.html(html);
86
+ } catch {
87
+ return c.text("Dashboard not built. Run: npm run build:dashboard", 404);
88
+ }
89
+ });
90
+
91
+ return app;
92
+ }
93
+
94
+ /**
95
+ * Start the dashboard server.
96
+ * Loads device tokens, then tries ports 3456-3460.
97
+ *
98
+ * @returns The port the server is listening on
99
+ */
100
+ export async function startDashboard(): Promise<number> {
101
+ await loadDeviceTokens();
102
+
103
+ const app = createApp();
104
+
105
+ for (const port of PORTS_TO_TRY) {
106
+ try {
107
+ await new Promise<void>((resolve, reject) => {
108
+ const server = serve({ fetch: app.fetch, port }, () => {
109
+ resolve();
110
+ });
111
+ server.on("error", reject);
112
+ });
113
+
114
+ setDashboardPort(port);
115
+ setBrowserCallDashboardPort(port);
116
+ setIntegrationsDashboardPort(port);
117
+ console.log(`Dashboard running at http://localhost:${port}`);
118
+ return port;
119
+ } catch (err: unknown) {
120
+ const error = err as { code?: string };
121
+ if (error.code === "EADDRINUSE") {
122
+ console.log(`Port ${port} in use, trying next...`);
123
+ continue;
124
+ }
125
+ throw err;
126
+ }
127
+ }
128
+
129
+ throw new Error(`All ports in use: ${PORTS_TO_TRY.join(", ")}`);
130
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true
11
+ },
12
+ "include": ["src"]
13
+ }
package/init/CLAUDE.md ADDED
@@ -0,0 +1,18 @@
1
+ # Claude Code Voice
2
+
3
+ You are a voice-controlled coding assistant. The user speaks to you and your responses are read aloud via TTS.
4
+
5
+ ## Response style
6
+
7
+ - **Be concise.** Your output is spoken, not read. Long responses are painful to listen to.
8
+ - **Be conversational.** Talk like a helpful colleague, not a manual.
9
+ - No emojis, no markdown formatting -- your output goes straight to a speech engine.
10
+ - When asked to do something, do it and give a brief confirmation. Don't narrate every step.
11
+ - If you need to show code or paths, keep explanations minimal -- the user can see your tool calls in the terminal.
12
+
13
+ ## Behavior
14
+
15
+ - You are a general-purpose assistant with full access to the user's machine via Claude Code.
16
+ - You can read, write, and edit files, run shell commands, search the web, and manage git.
17
+ - Prefer action over explanation. If the user asks you to do something, do it.
18
+ - Ask clarifying questions only when genuinely ambiguous -- don't over-confirm.
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "voicecc",
3
+ "version": "1.0.7",
4
+ "description": "Voice mode plugin for Claude Code -- hands-free interaction via local STT/TTS/VAD",
5
+ "type": "module",
6
+ "bin": {
7
+ "voicecc": "bin/voicecc.js"
8
+ },
9
+ "scripts": {
10
+ "start": "tsx run.ts",
11
+ "dev:dashboard": "cd dashboard && npx vite",
12
+ "build:dashboard": "cd dashboard && npx vite build",
13
+ "prepublishOnly": "npm run build:dashboard",
14
+ "postinstall": "node scripts/postinstall.js"
15
+ },
16
+ "files": [
17
+ "bin/",
18
+ "run.ts",
19
+ "sidecar/",
20
+ "services/",
21
+ "dashboard/dist/",
22
+ "dashboard/server.ts",
23
+ "dashboard/routes/",
24
+ "scripts/",
25
+ "init/",
26
+ "skills/",
27
+ ".claude-plugin/",
28
+ "tsconfig.json"
29
+ ],
30
+ "os": [
31
+ "darwin"
32
+ ],
33
+ "dependencies": {
34
+ "@anthropic-ai/claude-code": "^1.0.0",
35
+ "@anthropic-ai/sdk": "^0.39.0",
36
+ "@hono/node-server": "^1.19.9",
37
+ "avr-vad": "^1.0.0",
38
+ "dotenv": "^16.4.0",
39
+ "hono": "^4.12.0",
40
+ "node-record-lpcm16": "^1.0.1",
41
+ "qrcode.react": "^4.2.0",
42
+ "react": "^19.2.4",
43
+ "react-dom": "^19.2.4",
44
+ "react-router-dom": "^7.13.0",
45
+ "sherpa-onnx-node": "^1.11.0",
46
+ "twilio": "^5.0.0",
47
+ "ws": "^8.18.0",
48
+ "tsx": "^4.19.0"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^22.0.0",
52
+ "@types/react": "^19.2.14",
53
+ "@types/react-dom": "^19.2.3",
54
+ "@types/ws": "^8.5.0",
55
+ "@vitejs/plugin-react": "^5.1.4",
56
+ "typescript": "^5.7.0",
57
+ "vite": "^7.3.1"
58
+ }
59
+ }
package/run.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Top-level entry point that boots the dashboard server.
3
+ *
4
+ * Responsibilities:
5
+ * - Start the dashboard HTTP server (editor UI, conversation viewer, voice launcher)
6
+ * - Auto-start enabled integrations (Twilio, Browser Call) with tunnel as dependency
7
+ */
8
+
9
+ import { startDashboard } from "./dashboard/server.js";
10
+ import { readEnv } from "./services/env.js";
11
+ import { startTunnel, isTunnelRunning } from "./services/tunnel.js";
12
+ import { startTwilioServer } from "./services/twilio-manager.js";
13
+ import { startBrowserCallServer } from "./services/browser-call-manager.js";
14
+
15
+ // ============================================================================
16
+ // MAIN ENTRYPOINT
17
+ // ============================================================================
18
+
19
+ async function main(): Promise<void> {
20
+ const port = await startDashboard();
21
+
22
+ console.log("");
23
+ console.log("========================================");
24
+ console.log(" VOICECC RUNNING ");
25
+ console.log("========================================");
26
+ console.log("");
27
+ console.log(` Dashboard: http://localhost:${port}`);
28
+ console.log(" Press Ctrl+C to stop.");
29
+ console.log("");
30
+
31
+ const envVars = await readEnv();
32
+ const tunnelPort = parseInt(envVars.TWILIO_PORT || "8080", 10);
33
+
34
+ // Auto-start Twilio if enabled
35
+ if (envVars.TWILIO_ENABLED === "true") {
36
+ console.log("Twilio integration enabled, starting...");
37
+ try {
38
+ if (!isTunnelRunning()) {
39
+ await startTunnel(tunnelPort);
40
+ }
41
+ await startTwilioServer(port, undefined);
42
+ } catch (err) {
43
+ console.error(`Twilio auto-start failed: ${err}`);
44
+ }
45
+ }
46
+
47
+ // Auto-start Browser Call if enabled
48
+ if (envVars.BROWSER_CALL_ENABLED === "true") {
49
+ console.log("Browser Call integration enabled, starting...");
50
+ try {
51
+ if (!isTunnelRunning()) {
52
+ await startTunnel(tunnelPort);
53
+ }
54
+ await startBrowserCallServer(port);
55
+ } catch (err) {
56
+ console.error(`Browser Call auto-start failed: ${err}`);
57
+ }
58
+ }
59
+ }
60
+
61
+ // ============================================================================
62
+ // ENTRY POINT
63
+ // ============================================================================
64
+
65
+ main().catch((err) => {
66
+ console.error(`Startup failed: ${err}`);
67
+ process.exit(1);
68
+ });
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Postinstall script that runs after `npm install`.
3
+ *
4
+ * Compiles the mic-vpio Swift binary (macOS VPIO echo cancellation),
5
+ * checks for required system dependencies (espeak-ng), then sets up
6
+ * the Python virtual environment and installs TTS dependencies.
7
+ *
8
+ * Responsibilities:
9
+ * - Compile the mic-vpio Swift binary for echo-cancelled audio I/O
10
+ * - Verify espeak-ng is installed
11
+ * - Create Python venv in sidecar/.venv (if not already present)
12
+ * - Install Python TTS packages (mlx-audio, misaki, etc.)
13
+ * - Download spaCy English model
14
+ */
15
+
16
+ import { execSync } from "child_process";
17
+ import { copyFileSync, existsSync } from "fs";
18
+ import { join } from "path";
19
+
20
+ // ============================================================================
21
+ // CONSTANTS
22
+ // ============================================================================
23
+
24
+ const VENV_DIR = join("sidecar", ".venv");
25
+ const PIP = join(VENV_DIR, "bin", "pip");
26
+ const PYTHON = join(VENV_DIR, "bin", "python3");
27
+
28
+ const PYTHON_PACKAGES = [
29
+ "mlx-audio",
30
+ "misaki",
31
+ "num2words",
32
+ "spacy",
33
+ "phonemizer",
34
+ ];
35
+
36
+ // ============================================================================
37
+ // MAIN ENTRYPOINT
38
+ // ============================================================================
39
+
40
+ function main() {
41
+ installClaudeMd();
42
+ buildDashboard();
43
+ compileMicVpio();
44
+ checkSystemDeps();
45
+ setupPythonVenv();
46
+ installPythonPackages();
47
+ downloadSpacyModel();
48
+
49
+ console.log("");
50
+ console.log("========================================");
51
+ console.log(" VOICECC INSTALLED ");
52
+ console.log("========================================");
53
+ console.log("");
54
+ console.log(" Run 'voicecc' in terminal to start the server!");
55
+ console.log("");
56
+ }
57
+
58
+ // ============================================================================
59
+ // HELPER FUNCTIONS
60
+ // ============================================================================
61
+
62
+ /**
63
+ * Build the dashboard frontend via Vite.
64
+ */
65
+ function buildDashboard() {
66
+ console.log("Building dashboard...");
67
+ try {
68
+ run("cd dashboard && npx vite build");
69
+ } catch (err) {
70
+ console.error("\n[voicecc] ERROR: Failed to build dashboard.");
71
+ console.error(" Try manually: cd dashboard && npx vite build\n");
72
+ process.exit(1);
73
+ }
74
+ console.log("Dashboard built successfully");
75
+ }
76
+
77
+ /**
78
+ * Copy the project CLAUDE.md from init/ to the project root.
79
+ * Overwrites any existing CLAUDE.md to keep it in sync with the repo.
80
+ */
81
+ function installClaudeMd() {
82
+ const src = join("init", "CLAUDE.md");
83
+ const dest = "CLAUDE.md";
84
+
85
+ if (!existsSync(src)) {
86
+ console.warn("init/CLAUDE.md not found, skipping.");
87
+ return;
88
+ }
89
+
90
+ copyFileSync(src, dest);
91
+ console.log("Installed CLAUDE.md to project root.");
92
+ }
93
+
94
+ /**
95
+ * Compile the mic-vpio Swift binary for macOS VPIO echo cancellation.
96
+ * Skips compilation if the binary already exists and is newer than the source.
97
+ */
98
+ function compileMicVpio() {
99
+ const source = join("sidecar", "mic-vpio.swift");
100
+ const binary = join("sidecar", "mic-vpio");
101
+
102
+ if (process.platform !== "darwin") {
103
+ console.error("\n[voicecc] ERROR: macOS is required.");
104
+ console.error(" voicecc uses macOS VPIO for echo cancellation and mlx-audio for TTS.");
105
+ console.error(" It cannot run on Linux or Windows.\n");
106
+ process.exit(1);
107
+ }
108
+
109
+ if (!commandExists("swiftc")) {
110
+ console.error("\n[voicecc] ERROR: Swift compiler (swiftc) not found.");
111
+ console.error(" Install Xcode Command Line Tools: xcode-select --install\n");
112
+ process.exit(1);
113
+ }
114
+
115
+ console.log("Compiling mic-vpio (VPIO echo cancellation)...");
116
+ try {
117
+ run(`swiftc -O -o ${binary} ${source} -framework AudioToolbox -framework CoreAudio`);
118
+ } catch (err) {
119
+ console.error("\n[voicecc] ERROR: Failed to compile mic-vpio.swift.");
120
+ console.error(" Make sure Xcode Command Line Tools are installed: xcode-select --install\n");
121
+ process.exit(1);
122
+ }
123
+ console.log("mic-vpio compiled successfully");
124
+ }
125
+
126
+ /**
127
+ * Check that required system binaries are available.
128
+ * Exits with a clear error message if any are missing.
129
+ */
130
+ function checkSystemDeps() {
131
+ const missing = [];
132
+
133
+ if (!commandExists("espeak-ng")) missing.push("espeak-ng");
134
+
135
+ if (missing.length > 0) {
136
+ console.error(`\n[voicecc] ERROR: Missing system dependencies: ${missing.join(", ")}`);
137
+ console.error(` Install with: brew install ${missing.join(" ")}`);
138
+ console.error(` Then re-run: npm install\n`);
139
+ process.exit(1);
140
+ }
141
+
142
+ console.log("System dependencies OK (espeak-ng)");
143
+ }
144
+
145
+ /**
146
+ * Create the Python virtual environment if it doesn't exist.
147
+ */
148
+ function setupPythonVenv() {
149
+ if (existsSync(PIP)) {
150
+ console.log(`Python venv already exists at ${VENV_DIR}`);
151
+ return;
152
+ }
153
+
154
+ if (!commandExists("python3")) {
155
+ console.error("\n[voicecc] ERROR: python3 not found.");
156
+ console.error(" Install Python 3 via: brew install python3\n");
157
+ process.exit(1);
158
+ }
159
+
160
+ console.log(`Creating Python venv at ${VENV_DIR}...`);
161
+ try {
162
+ run(`python3 -m venv ${VENV_DIR}`);
163
+ } catch (err) {
164
+ console.error("\n[voicecc] ERROR: Failed to create Python virtual environment.");
165
+ console.error(" Make sure python3 is installed: brew install python3\n");
166
+ process.exit(1);
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Install Python TTS packages into the venv.
172
+ */
173
+ function installPythonPackages() {
174
+ console.log("Installing Python TTS packages...");
175
+ try {
176
+ run(`${PIP} install ${PYTHON_PACKAGES.join(" ")}`);
177
+ } catch (err) {
178
+ console.error("\n[voicecc] ERROR: Failed to install Python TTS packages.");
179
+ console.error(" This may be due to missing build tools or incompatible Python version.");
180
+ console.error(" Required packages: " + PYTHON_PACKAGES.join(", "));
181
+ console.error(" Try deleting sidecar/.venv and re-running: npm install\n");
182
+ process.exit(1);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Download the spaCy English language model.
188
+ */
189
+ function downloadSpacyModel() {
190
+ console.log("Downloading spaCy English model...");
191
+ try {
192
+ run(`${PYTHON} -m spacy download en_core_web_sm`);
193
+ } catch (err) {
194
+ console.error("\n[voicecc] ERROR: Failed to download spaCy English model.");
195
+ console.error(" Try manually: sidecar/.venv/bin/python3 -m spacy download en_core_web_sm\n");
196
+ process.exit(1);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Check if a command exists on the system PATH.
202
+ *
203
+ * @param {string} cmd - Command name to check
204
+ * @returns {boolean} True if the command exists
205
+ */
206
+ function commandExists(cmd) {
207
+ try {
208
+ execSync(`which ${cmd}`, { stdio: "ignore" });
209
+ return true;
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Run a shell command with inherited stdio (output visible to user).
217
+ *
218
+ * @param {string} cmd - Shell command to execute
219
+ */
220
+ function run(cmd) {
221
+ execSync(cmd, { stdio: "inherit" });
222
+ }
223
+
224
+ // ============================================================================
225
+ // ENTRY POINT
226
+ // ============================================================================
227
+
228
+ main();