From 1a36030cb717283aa8327bf2641ef05058d8cb99 Mon Sep 17 00:00:00 2001 From: maaz Date: Tue, 22 Sep 2026 00:07:20 -0500 Subject: [PATCH 1/2] feat: red theme, wake phrase, typed input and ElevenLabs voice - ElevenLabs TTS replaces Fish Audio when ELEVENLABS_API_KEY is set - page stays dark and silent until the wake phrase (VITE_WAKE_PHRASE) - text box for dictation tools such as Wispr Flow - blue palette recoloured red across orb, voice page and dashboard - scripts/start_jarvis.sh: idempotent launcher that opens Chrome --- .env.example | 5 + frontend/index.html | 4 +- frontend/src/dashboard/theme/shell.css | 12 +- frontend/src/dashboard/theme/tokens.css | 40 +++---- frontend/src/main.ts | 48 +++++++- frontend/src/orb.ts | 14 +-- frontend/src/style.css | 144 ++++++++++++++++-------- frontend/src/wake.ts | 46 ++++++++ preflight.py | 6 + scripts/start_jarvis.sh | 18 +++ server.py | 19 +++- tests/test_preflight.py | 16 +++ tests/test_tts.py | 30 +++++ tts.py | 48 ++++++++ 14 files changed, 366 insertions(+), 84 deletions(-) create mode 100644 frontend/src/wake.ts create mode 100755 scripts/start_jarvis.sh diff --git a/.env.example b/.env.example index b561d0a4..8f70b07b 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,11 @@ # Required: Fish Audio (JARVIS's voice) FISH_API_KEY=your-fish-audio-api-key-here +# Optional: ElevenLabs voice. When ELEVENLABS_API_KEY is set it replaces Fish Audio. +# ELEVENLABS_API_KEY= +# ELEVENLABS_VOICE_ID= +# ELEVENLABS_MODEL_ID=eleven_v3 # eleven_flash_v2_5 answers much faster + # JARVIS's brain is a Claude Code process running on YOUR Claude subscription. # Install Claude Code (2.1.224+) and log in with `claude` — no Anthropic API # key is needed for anything, and any ANTHROPIC_* variable set here is diff --git a/frontend/index.html b/frontend/index.html index 9440bda4..86aefbd8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,7 +6,7 @@ JARVIS - + @@ -35,6 +35,8 @@
JARVIS
+
+ diff --git a/frontend/src/dashboard/theme/shell.css b/frontend/src/dashboard/theme/shell.css index 88546e04..7a5c4e4d 100644 --- a/frontend/src/dashboard/theme/shell.css +++ b/frontend/src/dashboard/theme/shell.css @@ -37,10 +37,10 @@ body::before { z-index: 0; pointer-events: none; background: - radial-gradient(120% 90% at 12% -10%, rgba(76, 168, 232, 0.10), transparent 60%), - radial-gradient(90% 70% at 100% 0%, rgba(76, 168, 232, 0.05), transparent 55%), - repeating-linear-gradient(0deg, rgba(124, 196, 255, 0.022) 0 1px, transparent 1px 44px), - repeating-linear-gradient(90deg, rgba(124, 196, 255, 0.022) 0 1px, transparent 1px 44px); + radial-gradient(120% 90% at 12% -10%, rgba(232, 60, 60, 0.10), transparent 60%), + radial-gradient(90% 70% at 100% 0%, rgba(232, 60, 60, 0.05), transparent 55%), + repeating-linear-gradient(0deg, rgba(255, 120, 110, 0.022) 0 1px, transparent 1px 44px), + repeating-linear-gradient(90deg, rgba(255, 120, 110, 0.022) 0 1px, transparent 1px 44px); } /* Scan lines. Static — a moving scan line is a screensaver. */ body::after { @@ -59,7 +59,7 @@ body::after { position: sticky; top: 0; z-index: 10; - background: linear-gradient(var(--ground) 78%, rgba(5, 7, 12, 0.86)); + background: linear-gradient(var(--ground) 78%, rgba(12, 5, 5, 0.86)); backdrop-filter: blur(6px); border-bottom: var(--stroke) solid var(--edge); } @@ -244,7 +244,7 @@ body::after { padding: 7px var(--sp-4); border: var(--stroke) solid var(--tone); border-radius: var(--radius); - background: rgba(5, 7, 12, 0.94); + background: rgba(12, 5, 5, 0.94); box-shadow: var(--glow-md); color: var(--tone); font-size: var(--fs-sm); diff --git a/frontend/src/dashboard/theme/tokens.css b/frontend/src/dashboard/theme/tokens.css index 42dd26e4..c016cbed 100644 --- a/frontend/src/dashboard/theme/tokens.css +++ b/frontend/src/dashboard/theme/tokens.css @@ -4,8 +4,8 @@ The single place the look is defined. Everything downstream (primitives, views) refers to these names and never to a raw hue, size or duration. - Palette is lifted from the orb (frontend/src/orb.ts): a #05070c ground with - #4ca8e8 particles and white-hot highlights, so the dashboard reads as the + Palette is lifted from the orb (frontend/src/orb.ts): a #0c0505 ground with + #e83c3c particles and white-hot highlights, so the dashboard reads as the same machine as JARVIS's face. TONES @@ -18,45 +18,45 @@ :root { /* -- Ground & surfaces ------------------------------------------------- */ - --ground: #05070c; - --ground-lift: #070b12; - --surface: rgba(76, 168, 232, 0.035); - --surface-raised: rgba(76, 168, 232, 0.065); + --ground: #0c0505; + --ground-lift: #120707; + --surface: rgba(232, 60, 60, 0.035); + --surface-raised: rgba(232, 60, 60, 0.065); --surface-sunk: rgba(0, 0, 0, 0.35); - --scrim: rgba(4, 6, 11, 0.93); + --scrim: rgba(11, 4, 6, 0.93); /* The backdrop behind a sheet. Deliberately thinner than --scrim: what is under it is a live monitor, and dimming it to unreadable would defeat the reason the sheet does not cover the list in the first place. */ - --scrim-soft: rgba(4, 6, 11, 0.62); + --scrim-soft: rgba(11, 4, 6, 0.62); /* -- Edges ------------------------------------------------------------- */ - --edge: rgba(124, 196, 255, 0.13); - --edge-strong: rgba(124, 196, 255, 0.30); - --edge-hot: rgba(124, 196, 255, 0.55); + --edge: rgba(255, 120, 110, 0.13); + --edge-strong: rgba(255, 120, 110, 0.30); + --edge-hot: rgba(255, 120, 110, 0.55); --stroke: 1px; /* -- Signal hues. Reach for these only to define a tone. --------------- */ - --accent: #4ca8e8; - --accent-bright: #9bd8ff; + --accent: #e83c3c; + --accent-bright: #ffa89c; --signal-ok: #3ee0a6; --signal-warn: #ffb648; --signal-bad: #ff5a6e; --signal-idle: #7f8fa6; /* -- Text -------------------------------------------------------------- */ - --text: #dbe8f5; - --text-dim: rgba(219, 232, 245, 0.55); - --text-faint: rgba(219, 232, 245, 0.30); + --text: #f5e1de; + --text-dim: rgba(245, 222, 220, 0.55); + --text-faint: rgba(245, 222, 220, 0.30); /* -- Default tone. Overridden by the tone-* classes below. ------------- */ --tone: var(--accent); - --tone-soft: rgba(76, 168, 232, 0.14); + --tone-soft: rgba(232, 60, 60, 0.14); /* -- Glow. Restrained on purpose: a hologram is thin light, not neon. -- */ --glow-sm: 0 0 6px -1px var(--tone); --glow-md: 0 0 14px -2px var(--tone); --glow-panel: inset 0 0 40px -28px var(--accent-bright); - --glow-text: 0 0 12px rgba(155, 216, 255, 0.35); + --glow-text: 0 0 12px rgba(255, 150, 140, 0.35); /* -- Type -------------------------------------------------------------- */ --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, @@ -112,12 +112,12 @@ /* -- Tone classes ------------------------------------------------------------ Apply to any primitive to recolour its state-carrying parts. `--tone-soft` is the same hue at fill strength (backgrounds, tracks, wash). */ -.tone-accent { --tone: var(--accent); --tone-soft: rgba(76, 168, 232, 0.14); } +.tone-accent { --tone: var(--accent); --tone-soft: rgba(232, 60, 60, 0.14); } .tone-ok { --tone: var(--signal-ok); --tone-soft: rgba(62, 224, 166, 0.13); } .tone-warn { --tone: var(--signal-warn); --tone-soft: rgba(255, 182, 72, 0.13); } .tone-bad { --tone: var(--signal-bad); --tone-soft: rgba(255, 90, 110, 0.13); } .tone-idle { --tone: var(--signal-idle); --tone-soft: rgba(127, 143, 166, 0.12); } -.tone-dim { --tone: var(--text-faint); --tone-soft: rgba(219, 232, 245, 0.07); } +.tone-dim { --tone: var(--text-faint); --tone-soft: rgba(245, 222, 220, 0.07); } /* -- Reduced motion ---------------------------------------------------------- Nothing in this UI depends on movement to be understood: every animated diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 9c265652..58560de8 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -9,6 +9,7 @@ import { createOrb, type OrbState } from "./orb"; import { createVoiceInput, createAudioPlayer, createMicMonitor } from "./voice"; import { createSocket } from "./ws"; import { openSettings, checkFirstTimeSetup } from "./settings"; +import { matchWake, wakePhrase } from "./wake"; import "./style.css"; // --------------------------------------------------------------------------- @@ -19,6 +20,29 @@ type State = "idle" | "listening" | "thinking" | "speaking" | "compacting"; let currentState: State = "idle"; let isMuted = false; +// Asleep until the wake phrase is heard: nothing shown, nothing spoken. +const WAKE_PHRASE = wakePhrase(); +let awake = false; + +function wake() { + if (awake) return; + awake = true; + document.body.classList.remove("asleep"); + document.body.classList.add("awake"); +} + +/** Every transcript, spoken or typed, goes through here. */ +function handleUserText(text: string) { + if (awake) { + socket.send({ type: "transcript", text, isFinal: true }); + return; + } + const { woke, remainder } = matchWake(text, WAKE_PHRASE); + if (!woke) return; + wake(); + socket.send({ type: "transcript", text: remainder || "Hello JARVIS", isFinal: true }); +} + const statusEl = document.getElementById("status-text")!; const errorEl = document.getElementById("error-text")!; @@ -81,11 +105,11 @@ const voiceInput = createVoiceInput( (text: string) => { // The server decides whether this is echo, a barge-in, or a new turn. micMonitor.sawSpeech(); - socket.send({ type: "transcript", text, isFinal: true }); + handleUserText(text); }, (text: string) => { micMonitor.sawSpeech(); - socket.send({ type: "interim", text }); + if (awake) socket.send({ type: "interim", text }); }, (msg: string) => { showError(msg); @@ -170,7 +194,11 @@ socket.onMessage((msg) => { muteMicDuringSpeech = Boolean(msg.muteMicDuringSpeech); } else if (type === "audio") { const data = msg.data as string; - if (data) { + if (data && !awake) { + // Asleep: stay silent, but ack the chunk so the server is not left + // waiting on a playback that will never happen. + socket.send({ type: "played", utt: Number(msg.utt), idx: Number(msg.idx) }); + } else if (data) { if (currentState !== "speaking") transition("speaking"); audioPlayer.enqueue(data, Number(msg.utt), Number(msg.idx)); } @@ -200,10 +228,24 @@ socket.onMessage((msg) => { } }); +// ── typed input ────────────────────────────────────────────────────────── +// For dictation tools (Wispr Flow) that type rather than speak into the page. +const typeBox = document.getElementById("type-box") as HTMLInputElement | null; +typeBox?.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key !== "Enter") return; + e.preventDefault(); + const text = typeBox.value.trim(); + typeBox.value = ""; + if (text) handleUserText(text); +}); + // --------------------------------------------------------------------------- // Kick off // --------------------------------------------------------------------------- +const sleepHint = document.getElementById("sleep-hint"); +if (sleepHint) sleepHint.textContent = `say "${WAKE_PHRASE}"`; + // Start listening after a brief delay for the orb to render setTimeout(() => { voiceInput.start(); diff --git a/frontend/src/orb.ts b/frontend/src/orb.ts index eb0450b5..14a0b53f 100644 --- a/frontend/src/orb.ts +++ b/frontend/src/orb.ts @@ -28,7 +28,7 @@ export function createOrb(canvas: HTMLCanvasElement): Orb { const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(window.innerWidth, window.innerHeight); - renderer.setClearColor(0x050508, 1); + renderer.setClearColor(0x080505, 1); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 1000); @@ -53,7 +53,7 @@ export function createOrb(canvas: HTMLCanvasElement): Orb { geo.setAttribute("position", new THREE.BufferAttribute(pos, 3)); const mat = new THREE.PointsMaterial({ - color: 0x4ca8e8, size: 0.4, transparent: true, opacity: 0.6, + color: 0xe83c3c, size: 0.4, transparent: true, opacity: 0.6, sizeAttenuation: true, blending: THREE.AdditiveBlending, depthWrite: false, }); @@ -68,7 +68,7 @@ export function createOrb(canvas: HTMLCanvasElement): Orb { lineGeo.setDrawRange(0, 0); const lineMat = new THREE.LineBasicMaterial({ - color: 0x4ca8e8, transparent: true, opacity: 0.0, + color: 0xe83c3c, transparent: true, opacity: 0.0, blending: THREE.AdditiveBlending, depthWrite: false, }); @@ -313,10 +313,10 @@ export function createOrb(canvas: HTMLCanvasElement): Orb { mat.opacity = currentBright + bass * 0.08; mat.size = currentSize + bass * 0.05; - if (state === "thinking") { mat.color.lerp(new THREE.Color(0x6ec4ff), 0.015); lineMat.color.lerp(new THREE.Color(0x6ec4ff), 0.015); } - else if (state === "speaking") { mat.color.lerp(new THREE.Color(0x5ab8f0), 0.015); lineMat.color.lerp(new THREE.Color(0x5ab8f0), 0.015); } - else if (state === "compacting") { mat.color.lerp(new THREE.Color(0x3a5f8a), 0.03); lineMat.color.lerp(new THREE.Color(0x3a5f8a), 0.03); } // desaturated, cooler - else { mat.color.lerp(new THREE.Color(0x4ca8e8), 0.015); lineMat.color.lerp(new THREE.Color(0x4ca8e8), 0.015); } + if (state === "thinking") { mat.color.lerp(new THREE.Color(0xff5a4e), 0.015); lineMat.color.lerp(new THREE.Color(0xff5a4e), 0.015); } + else if (state === "speaking") { mat.color.lerp(new THREE.Color(0xf04a44), 0.015); lineMat.color.lerp(new THREE.Color(0xf04a44), 0.015); } + else if (state === "compacting") { mat.color.lerp(new THREE.Color(0x8a3a3a), 0.03); lineMat.color.lerp(new THREE.Color(0x8a3a3a), 0.03); } // desaturated, cooler + else { mat.color.lerp(new THREE.Color(0xe83c3c), 0.015); lineMat.color.lerp(new THREE.Color(0xe83c3c), 0.015); } camera.position.x = Math.sin(t * 0.02) * 5; camera.position.y = Math.cos(t * 0.03) * 3; diff --git a/frontend/src/style.css b/frontend/src/style.css index 59142c13..39f4a7f7 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -8,7 +8,7 @@ html, body { width: 100%; height: 100%; overflow: hidden; - background: #050508; + background: #080505; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } @@ -25,7 +25,7 @@ html, body { bottom: 40px; left: 50%; transform: translateX(-50%); - color: rgba(14, 165, 233, 0.5); + color: rgba(230, 30, 40, 0.5); font-size: 13px; letter-spacing: 2px; text-transform: lowercase; @@ -40,7 +40,7 @@ html, body { bottom: 16px; left: 50%; transform: translateX(-50%); - color: rgba(14, 165, 233, 0.2); + color: rgba(230, 30, 40, 0.2); font-size: 10px; letter-spacing: 4px; text-transform: uppercase; @@ -61,10 +61,10 @@ html, body { #controls button { width: 36px; height: 36px; - border: 1px solid rgba(14, 165, 233, 0.15); + border: 1px solid rgba(230, 30, 40, 0.15); border-radius: 8px; - background: rgba(14, 165, 233, 0.05); - color: rgba(14, 165, 233, 0.4); + background: rgba(230, 30, 40, 0.05); + color: rgba(230, 30, 40, 0.4); cursor: pointer; display: flex; align-items: center; @@ -73,9 +73,9 @@ html, body { } #controls button:hover { - background: rgba(14, 165, 233, 0.12); - color: rgba(14, 165, 233, 0.7); - border-color: rgba(14, 165, 233, 0.3); + background: rgba(230, 30, 40, 0.12); + color: rgba(230, 30, 40, 0.7); + border-color: rgba(230, 30, 40, 0.3); } #controls button.muted { @@ -88,7 +88,7 @@ html, body { top: 58px; right: 16px; background: rgba(10, 10, 15, 0.95); - border: 1px solid rgba(14, 165, 233, 0.15); + border: 1px solid rgba(230, 30, 40, 0.15); border-radius: 8px; padding: 4px; z-index: 20; @@ -101,7 +101,7 @@ html, body { padding: 8px 16px; border: none; background: none; - color: rgba(14, 165, 233, 0.5); + color: rgba(230, 30, 40, 0.5); font-size: 12px; letter-spacing: 1px; text-align: left; @@ -111,8 +111,8 @@ html, body { } #menu-dropdown button:hover { - background: rgba(14, 165, 233, 0.1); - color: rgba(14, 165, 233, 0.8); + background: rgba(230, 30, 40, 0.1); + color: rgba(230, 30, 40, 0.8); } #error-text { @@ -166,8 +166,8 @@ html, body { width: 420px; max-width: 100%; height: 100%; - background: rgba(8, 10, 18, 0.92); - border-left: 1px solid rgba(0, 212, 255, 0.12); + background: rgba(18, 8, 8, 0.92); + border-left: 1px solid rgba(255, 48, 48, 0.12); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); transform: translateX(100%); @@ -186,7 +186,7 @@ html, body { justify-content: space-between; align-items: center; padding: 20px 24px; - border-bottom: 1px solid rgba(0, 212, 255, 0.08); + border-bottom: 1px solid rgba(255, 48, 48, 0.08); } .settings-header h2 { @@ -195,16 +195,16 @@ html, body { font-weight: 400; letter-spacing: 2px; text-transform: uppercase; - color: rgba(0, 212, 255, 0.7); + color: rgba(255, 48, 48, 0.7); } .settings-close { width: 32px; height: 32px; - border: 1px solid rgba(0, 212, 255, 0.15); + border: 1px solid rgba(255, 48, 48, 0.15); border-radius: 6px; - background: rgba(0, 212, 255, 0.05); - color: rgba(0, 212, 255, 0.5); + background: rgba(255, 48, 48, 0.05); + color: rgba(255, 48, 48, 0.5); font-size: 18px; cursor: pointer; display: flex; @@ -214,19 +214,19 @@ html, body { } .settings-close:hover { - background: rgba(0, 212, 255, 0.12); - color: rgba(0, 212, 255, 0.8); - border-color: rgba(0, 212, 255, 0.3); + background: rgba(255, 48, 48, 0.12); + color: rgba(255, 48, 48, 0.8); + border-color: rgba(255, 48, 48, 0.3); } .settings-welcome { padding: 16px 24px; - border-bottom: 1px solid rgba(0, 212, 255, 0.08); + border-bottom: 1px solid rgba(255, 48, 48, 0.08); } .settings-welcome p { margin: 0; - color: rgba(0, 212, 255, 0.6); + color: rgba(255, 48, 48, 0.6); font-size: 14px; letter-spacing: 0.5px; } @@ -287,7 +287,7 @@ html, body { .settings-field input:focus, .settings-field select:focus, .settings-field textarea:focus { - border-color: rgba(0, 212, 255, 0.3); + border-color: rgba(255, 48, 48, 0.3); } .settings-field select { @@ -315,15 +315,15 @@ html, body { margin: 8px 0 0 0; font-size: 12px; text-align: right; - color: rgba(0, 212, 255, 0.7); + color: rgba(255, 48, 48, 0.7); } .settings-btn { padding: 7px 14px; - border: 1px solid rgba(0, 212, 255, 0.2); + border: 1px solid rgba(255, 48, 48, 0.2); border-radius: 6px; - background: rgba(0, 212, 255, 0.06); - color: rgba(0, 212, 255, 0.6); + background: rgba(255, 48, 48, 0.06); + color: rgba(255, 48, 48, 0.6); font-size: 12px; letter-spacing: 0.5px; cursor: pointer; @@ -332,20 +332,20 @@ html, body { } .settings-btn:hover { - background: rgba(0, 212, 255, 0.12); - color: rgba(0, 212, 255, 0.9); - border-color: rgba(0, 212, 255, 0.35); + background: rgba(255, 48, 48, 0.12); + color: rgba(255, 48, 48, 0.9); + border-color: rgba(255, 48, 48, 0.35); } .settings-btn.primary { - background: rgba(0, 212, 255, 0.12); - border-color: rgba(0, 212, 255, 0.3); - color: rgba(0, 212, 255, 0.8); + background: rgba(255, 48, 48, 0.12); + border-color: rgba(255, 48, 48, 0.3); + color: rgba(255, 48, 48, 0.8); } .settings-btn.primary:hover { - background: rgba(0, 212, 255, 0.2); - color: #00d4ff; + background: rgba(255, 48, 48, 0.2); + color: #ff3030; } .settings-actions { @@ -422,7 +422,7 @@ html, body { } .sysinfo-row span:last-child { - color: rgba(0, 212, 255, 0.6); + color: rgba(255, 48, 48, 0.6); font-variant-numeric: tabular-nums; } @@ -452,7 +452,7 @@ html, body { display: block; height: 100%; width: var(--level, 0%); - background: #4fd1ff; + background: #ff4f4f; opacity: 0.55; transition: width 0.08s linear; } @@ -470,14 +470,70 @@ html, body { font-size: 13px; letter-spacing: 0.16em; text-transform: uppercase; - color: #9fd8ef; - background: rgba(10, 22, 32, 0.72); - border: 1px solid rgba(79, 209, 255, 0.35); + color: #efa89f; + background: rgba(32, 10, 10, 0.72); + border: 1px solid rgba(255, 70, 70, 0.35); border-radius: 999px; cursor: pointer; z-index: 40; backdrop-filter: blur(4px); transition: border-color 0.15s ease, color 0.15s ease; } -#hush:hover { color: #eaf8ff; border-color: rgba(79, 209, 255, 0.75); } +#hush:hover { color: #fff0ee; border-color: rgba(255, 70, 70, 0.75); } #hush[hidden] { display: none; } + +/* ── asleep until the wake phrase ─────────────────────────────────────── */ +#orb-canvas, +#controls, +#status-text, +#jarvis-label, +#mic-level { + transition: opacity 0.8s ease; +} + +body.asleep #orb-canvas, +body.asleep #controls, +body.asleep #status-text, +body.asleep #jarvis-label, +body.asleep #hush { + opacity: 0; + pointer-events: none; +} + +#sleep-hint { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: rgba(230, 30, 40, 0.35); + font-size: 12px; + letter-spacing: 3px; + text-transform: lowercase; + font-weight: 300; + pointer-events: none; + transition: opacity 0.8s ease; + z-index: 10; +} + +body.awake #sleep-hint { opacity: 0; } + +/* ── typed / dictated input (Wispr Flow) ──────────────────────────────── */ +#type-box { + position: fixed; + bottom: 64px; + left: 50%; + transform: translateX(-50%); + width: min(520px, calc(100% - 32px)); + padding: 10px 14px; + background: rgba(18, 8, 8, 0.7); + border: 1px solid rgba(230, 30, 40, 0.2); + border-radius: 8px; + color: rgba(245, 222, 220, 0.9); + font-size: 14px; + font-family: inherit; + outline: none; + z-index: 20; +} + +#type-box::placeholder { color: rgba(230, 30, 40, 0.35); } +#type-box:focus { border-color: rgba(230, 30, 40, 0.55); } diff --git a/frontend/src/wake.ts b/frontend/src/wake.ts new file mode 100644 index 00000000..b45e8859 --- /dev/null +++ b/frontend/src/wake.ts @@ -0,0 +1,46 @@ +/// +/** + * Wake phrase — JARVIS stays dark and silent until he hears it. + * + * The phrase comes from VITE_WAKE_PHRASE (frontend/.env.local) and defaults + * to "hey jarvis". Matching ignores case and punctuation, because the speech + * recogniser and dictation tools (Wispr Flow) punctuate as they please. + */ + +export const DEFAULT_WAKE_PHRASE = "hey jarvis"; + +export function normalise(text: string): string { + return text + .toLowerCase() + .replace(/[^\p{L}\p{N}\s]/gu, " ") + .replace(/\s+/g, " ") + .trim(); +} + +export interface WakeMatch { + woke: boolean; + /** Whatever was said after the phrase, ready to send as the first turn. */ + remainder: string; +} + +export function matchWake(text: string, phrase: string): WakeMatch { + const want = normalise(phrase).split(" ").filter(Boolean); + if (want.length === 0) return { woke: true, remainder: text.trim() }; + // Walk the ORIGINAL words so the remainder keeps its casing and punctuation. + const words = [...text.matchAll(/[\p{L}\p{N}]+(?:['’][\p{L}\p{N}]+)*/gu)]; + const norm = words.map((w) => normalise(w[0]).replace(/ /g, "")); + for (let i = 0; i + want.length <= words.length; i++) { + if (want.every((w, j) => norm[i + j] === w)) { + const last = words[i + want.length - 1]; + const cut = (last.index ?? 0) + last[0].length; + const remainder = text.slice(cut).replace(/^[\s,.;:!?—-]+/u, "").trim(); + return { woke: true, remainder }; + } + } + return { woke: false, remainder: "" }; +} + +export function wakePhrase(): string { + const configured = (import.meta.env.VITE_WAKE_PHRASE as string | undefined) ?? ""; + return configured.trim() || DEFAULT_WAKE_PHRASE; +} diff --git a/preflight.py b/preflight.py index 01adb00d..4e1dce94 100644 --- a/preflight.py +++ b/preflight.py @@ -441,6 +441,12 @@ def _check_screen_recording_sync() -> Check: def _check_fish_api_key_sync() -> Check: """FISH_API_KEY must be set or JARVIS has no voice.""" + if os.environ.get("ELEVENLABS_API_KEY"): + if os.environ.get("ELEVENLABS_VOICE_ID"): + return Check(name="fish_api_key", status=STATUS_OK, message="ElevenLabs is the voice.") + return Check(name="fish_api_key", status=STATUS_FAIL, + message="ELEVENLABS_API_KEY is set but ELEVENLABS_VOICE_ID is not.", + remedy="Set ELEVENLABS_VOICE_ID in .env to the voice's ID from ElevenLabs.") if os.environ.get("FISH_API_KEY"): return Check(name="fish_api_key", status=STATUS_OK, message="FISH_API_KEY is set.") return Check( diff --git a/scripts/start_jarvis.sh b/scripts/start_jarvis.sh new file mode 100755 index 00000000..45a03309 --- /dev/null +++ b/scripts/start_jarvis.sh @@ -0,0 +1,18 @@ +#!/bin/zsh +# Start JARVIS (backend + frontend) if it is not already running, and open it +# in Chrome. Safe to run repeatedly: a running instance is left alone. +cd "$(dirname "$0")/.." || exit 1 +mkdir -p data/logs +started=0 +if ! curl -sk -o /dev/null https://localhost:8340/api/health; then + nohup .venv/bin/python server.py --host 127.0.0.1 > data/logs/server.log 2>&1 & + started=1 +fi +if ! curl -s -o /dev/null http://localhost:5173; then + (cd frontend && nohup npx vite > ../data/logs/vite.log 2>&1 &) + started=1 +fi +if [ "$started" = 1 ]; then + for _ in {1..30}; do curl -s -o /dev/null http://localhost:5173 && break; sleep 1; done + open -a "Google Chrome" http://localhost:5173 +fi diff --git a/server.py b/server.py index 4d39b059..9ff28ca2 100644 --- a/server.py +++ b/server.py @@ -98,6 +98,10 @@ def _parse_env_lines(text: str) -> list[tuple[str, str]]: FISH_API_KEY = os.getenv("FISH_API_KEY", "") FISH_VOICE_ID = os.getenv("FISH_VOICE_ID", "612b878b113047d9a770c069c8b4fdfe") # JARVIS (MCU) FISH_API_URL = "https://api.fish.audio/v1/tts" +# ElevenLabs takes over the voice whenever its key is set. +ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY", "") +ELEVENLABS_VOICE_ID = os.getenv("ELEVENLABS_VOICE_ID", "") +ELEVENLABS_MODEL_ID = os.getenv("ELEVENLABS_MODEL_ID", "eleven_v3") USER_NAME = os.getenv("USER_NAME", "sir") _SKIP_PERMISSIONS = os.getenv("JARVIS_SKIP_PERMISSIONS", "true").lower() not in ("0", "false", "no") @@ -461,7 +465,11 @@ def format_runs_for_prompt() -> str: # --------------------------------------------------------------------------- async def synthesize_speech(text: str) -> Optional[bytes]: - """Generate speech audio from text using Fish Audio TTS.""" + """Generate speech audio from text — ElevenLabs when configured, else Fish Audio.""" + if ELEVENLABS_API_KEY: + r = await tts.synthesize_elevenlabs(text, api_key=ELEVENLABS_API_KEY, voice_id=ELEVENLABS_VOICE_ID, + model_id=ELEVENLABS_MODEL_ID) + return r.audio if r else None if not FISH_API_KEY: log.warning("FISH_API_KEY not set, skipping TTS") return None @@ -611,7 +619,11 @@ async def _voice_emit(msg: dict) -> None: async def _synth_for_speech(text: str) -> Optional[bytes]: - r = await tts.synthesize_chunk(text, api_key=FISH_API_KEY, voice_id=FISH_VOICE_ID, client=_tts_client) + if ELEVENLABS_API_KEY: + r = await tts.synthesize_elevenlabs(text, api_key=ELEVENLABS_API_KEY, voice_id=ELEVENLABS_VOICE_ID, + model_id=ELEVENLABS_MODEL_ID, client=_tts_client) + else: + r = await tts.synthesize_chunk(text, api_key=FISH_API_KEY, voice_id=FISH_VOICE_ID, client=_tts_client) if r is None: return None _session_tokens["tts_calls"] += 1 @@ -6879,7 +6891,8 @@ async def api_settings_status(): "server_port": 8340, "uptime_seconds": int(time.time() - _session_start), "env_keys_set": { - "fish_audio": bool(env_dict.get("FISH_API_KEY", "").strip() and env_dict.get("FISH_API_KEY", "") != "your-fish-audio-api-key-here"), + # Any working voice counts: ElevenLabs replaces Fish Audio when set. + "fish_audio": bool(env_dict.get("ELEVENLABS_API_KEY", "").strip()) or bool(env_dict.get("FISH_API_KEY", "").strip() and env_dict.get("FISH_API_KEY", "") != "your-fish-audio-api-key-here"), "fish_voice_id": bool(env_dict.get("FISH_VOICE_ID", "").strip()), "user_name": env_dict.get("USER_NAME", ""), }, diff --git a/tests/test_preflight.py b/tests/test_preflight.py index f37eb27b..540b69bb 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -391,11 +391,27 @@ def test_fish_api_key_present(monkeypatch): def test_fish_api_key_absent(monkeypatch): monkeypatch.delenv("FISH_API_KEY", raising=False) + monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False) check = preflight._check_fish_api_key_sync() assert check.status == STATUS_FAIL assert check.remedy +def test_elevenlabs_key_with_voice_is_ok(monkeypatch): + monkeypatch.delenv("FISH_API_KEY", raising=False) + monkeypatch.setenv("ELEVENLABS_API_KEY", "sk_test") + monkeypatch.setenv("ELEVENLABS_VOICE_ID", "VOICE") + assert preflight._check_fish_api_key_sync().status == STATUS_OK + + +def test_elevenlabs_key_without_voice_id_fails_loudly(monkeypatch): + monkeypatch.setenv("ELEVENLABS_API_KEY", "sk_test") + monkeypatch.delenv("ELEVENLABS_VOICE_ID", raising=False) + check = preflight._check_fish_api_key_sync() + assert check.status == STATUS_FAIL + assert "ELEVENLABS_VOICE_ID" in check.remedy + + # --- leftover ANTHROPIC_* ------------------------------------------------------ def test_leftover_anthropic_key_warns(monkeypatch): diff --git a/tests/test_tts.py b/tests/test_tts.py index 6f068add..d2b5d3ea 100644 --- a/tests/test_tts.py +++ b/tests/test_tts.py @@ -60,3 +60,33 @@ async def test_empty_text_or_missing_key_short_circuits(): assert await tts.synthesize_chunk(" ", api_key="k", voice_id="v", client=c) is None assert await tts.synthesize_chunk("hi", api_key="", voice_id="v", client=c) is None assert calls == [] + + +@pytest.mark.asyncio +async def test_elevenlabs_sends_voice_model_and_key_and_returns_the_mp3(): + import tts + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + seen["key"] = request.headers.get("xi-api-key") + seen["body"] = json.loads(request.content) + return httpx.Response(200, content=b"ID3mp3") + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + r = await tts.synthesize_elevenlabs("Good evening.", api_key="k", voice_id="VOICE", + model_id="eleven_v3", client=client) + assert r is not None and r.audio == b"ID3mp3" + assert "/v1/text-to-speech/VOICE" in seen["url"] + assert seen["key"] == "k" + assert seen["body"] == {"text": "Good evening.", "model_id": "eleven_v3"} + + +@pytest.mark.asyncio +async def test_elevenlabs_error_is_none_not_an_exception(): + import tts + transport = httpx.MockTransport(lambda req: httpx.Response(401, json={"detail": "bad key"})) + async with httpx.AsyncClient(transport=transport) as client: + r = await tts.synthesize_elevenlabs("Hello.", api_key="bad", voice_id="V", + model_id="eleven_v3", client=client) + assert r is None diff --git a/tts.py b/tts.py index 722c001a..24832361 100644 --- a/tts.py +++ b/tts.py @@ -62,3 +62,51 @@ async def synthesize_chunk(text: str, *, api_key: str, voice_id: str, if not buf: return None return SynthResult(bytes(buf), first if first is not None else 0.0, time.monotonic() - t0) + + +ELEVENLABS_TTS_URL = "https://api.elevenlabs.io/v1/text-to-speech/{voice_id}" + + +async def synthesize_elevenlabs(text: str, *, api_key: str, voice_id: str, model_id: str, + client: Optional[httpx.AsyncClient] = None, + timeout: float = 30.0) -> Optional[SynthResult]: + """ElevenLabs synthesis — same contract as `synthesize_chunk`: one MP3, or None.""" + text = (text or "").strip() + if not text or not api_key: + return None + if not voice_id: + log.error("ELEVENLABS_API_KEY is set but ELEVENLABS_VOICE_ID is not; no voice") + return None + own = client is None + client = client or httpx.AsyncClient(timeout=timeout) + t0 = time.monotonic() + first: Optional[float] = None + buf = bytearray() + try: + async with client.stream( + "POST", ELEVENLABS_TTS_URL.format(voice_id=voice_id), + params={"output_format": "mp3_44100_128"}, + headers={"xi-api-key": api_key, "Content-Type": "application/json", "Accept": "audio/mpeg"}, + json={"text": text, "model_id": model_id}, + timeout=timeout, + ) as resp: + if resp.status_code != 200: + body = (await resp.aread())[:200] + log.error(f"ElevenLabs TTS {resp.status_code} for {text[:40]!r}: {body!r}") + return None + async for part in resp.aiter_bytes(): + if first is None: + first = time.monotonic() - t0 + buf.extend(part) + except (httpx.HTTPError, OSError) as e: + log.error(f"ElevenLabs TTS error: {e}") + return None + finally: + if own: + try: + await client.aclose() + except Exception as e: + log.debug(f"TTS client close failed: {e}") + if not buf: + return None + return SynthResult(bytes(buf), first if first is not None else 0.0, time.monotonic() - t0) From 5ffb761ed0e51a46f9d611aa3bff2f8dada17aab Mon Sep 17 00:00:00 2001 From: Maaz Hasan Date: Tue, 29 Sep 2026 00:17:54 -0500 Subject: [PATCH 2/2] test: stub gh_path so github-lookup tests run without the gh CLI The fixture faked _run_gh but not gh_path(), so on a machine without gh every lookup returned no_gh before reaching the fake (10 failures). --- tests/test_github_lookup.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_github_lookup.py b/tests/test_github_lookup.py index 2879f8e6..58fa7ba3 100644 --- a/tests/test_github_lookup.py +++ b/tests/test_github_lookup.py @@ -107,6 +107,9 @@ def when(self, match, rc=0, out="", err=""): fake = _Gh() monkeypatch.setattr(gh_lookup, "_run_gh", fake.run) + # Pretend gh is installed: _run_gh is faked, and on a machine without the gh CLI the + # real gh_path() returns None and look_up() bails out before reaching the fake. + monkeypatch.setattr(gh_lookup, "gh_path", lambda: "/usr/local/bin/gh") # The login is looked up once and cached; never let a test hit the real one. fake.when(lambda a: a[:2] == ["api", "user"], out="tonystark\n") return gh_lookup, fake