Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 3 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<title>JARVIS</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<body class="asleep">
<canvas id="orb-canvas"></canvas>

<!-- Minimal controls — top right -->
Expand Down Expand Up @@ -35,6 +35,8 @@
<div id="status-text"></div>
<div id="jarvis-label">JARVIS</div>
<div id="error-text"></div>
<div id="sleep-hint"></div>
<input id="type-box" type="text" autocomplete="off" placeholder="Type or dictate to JARVIS, then Enter" />
<script type="module" src="/src/main.ts"></script>
</body>
</html>
12 changes: 6 additions & 6 deletions frontend/src/dashboard/theme/shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
40 changes: 20 additions & 20 deletions frontend/src/dashboard/theme/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
48 changes: 45 additions & 3 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// ---------------------------------------------------------------------------
Expand All @@ -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")!;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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();
Expand Down
14 changes: 7 additions & 7 deletions frontend/src/orb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
});

Expand All @@ -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,
});

Expand Down Expand Up @@ -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;
Expand Down
Loading