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_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
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)