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
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@
ANTHROPIC_API_KEY=your-anthropic-api-key-here
FISH_API_KEY=your-fish-audio-api-key-here

# Optional: which speech synthesiser to use.
# auto (default) — Fish Audio when its key is set, otherwise macOS voices
# fish — always Fish Audio
# macos — always the built-in macOS voices: free, offline, no key
# none — silent; replies appear as on-screen captions only
# TTS_BACKEND=auto

# Optional: which macOS voice to speak with (see `say -v ?`).
# Empty picks the best installed voice for SPEECH_LANG.
# MACOS_VOICE=Alice

# Optional: Fish Audio voice model (defaults to JARVIS MCU voice)
# FISH_VOICE_ID=612b878b113047d9a770c069c8b4fdfe

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ __pycache__/
*.db
data/*.jsonl
data/active_session.json
data/phrases-*.json
data/.jarvis_output.txt

# Certificates
Expand Down
8 changes: 5 additions & 3 deletions calendar_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import asyncio
import logging

from phrases import phrase
import os
import time as _time
from datetime import datetime, timedelta
Expand Down Expand Up @@ -245,14 +247,14 @@ def format_events_for_context(events: list[dict]) -> str:
def format_schedule_summary(events: list[dict]) -> str:
"""Format a brief voice-friendly summary of the schedule."""
if not events:
return "Your schedule is clear today, sir."
return phrase("calendar.clear")

count = len(events)
if count == 1:
evt = events[0]
if evt.get("all_day"):
return f"You have one all-day event: {evt['title']}."
return f"You have one event: {evt['title']} at {evt['start']}."
return phrase("calendar.one_all_day", title=evt["title"])
return phrase("calendar.one_event", title=evt["title"], time=evt["start"])

summaries = []
for evt in events[:5]:
Expand Down
1 change: 1 addition & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<button id="btn-fix-self">Fix Yourself</button>
</div>

<div id="jarvis-caption"></div>
<div id="status-text"></div>
<div id="jarvis-label">JARVIS</div>
<div id="error-text"></div>
Expand Down
26 changes: 13 additions & 13 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

113 changes: 107 additions & 6 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { createOrb, type OrbState } from "./orb";
import { createVoiceInput, createAudioPlayer } from "./voice";
import { createSocket } from "./ws";
import { openSettings, checkFirstTimeSetup } from "./settings";
import { openSettings, checkFirstTimeSetup, SPEECH_LANG_EVENT } from "./settings";
import "./style.css";

// ---------------------------------------------------------------------------
Expand All @@ -21,6 +21,7 @@ let isMuted = false;

const statusEl = document.getElementById("status-text")!;
const errorEl = document.getElementById("error-text")!;
const captionEl = document.getElementById("jarvis-caption")!;

function showError(msg: string) {
errorEl.textContent = msg;
Expand All @@ -30,6 +31,28 @@ function showError(msg: string) {
}, 5000);
}

let captionTimer: number | undefined;

/**
* Put what JARVIS said on screen.
*
* Shown whether or not TTS produced audio: with a voice it reads as
* subtitles, and without one it is the only way to see the reply — the
* response used to reach nothing but the devtools console.
*/
function showCaption(text: unknown) {
if (typeof text !== "string" || !text.trim()) return;
clearTimeout(captionTimer);
captionEl.textContent = text;
captionEl.style.opacity = "1";
// Hold long enough to read the line, since a caption may be all the user
// gets. Replies are a sentence or two, so length is a fair proxy.
const holdMs = Math.min(20000, 4000 + text.length * 60);
captionTimer = window.setTimeout(() => {
captionEl.style.opacity = "0";
}, holdMs);
}

function updateStatus(state: State) {
const labels: Record<State, string> = {
idle: "",
Expand Down Expand Up @@ -80,13 +103,59 @@ function transition(newState: State) {
// Voice input
// ---------------------------------------------------------------------------

/**
* Fragment accumulation.
*
* Chrome closes a recognition segment at every pause, so a sentence spoken
* with any hesitation arrives as several "final" results. Sending each one
* immediately made JARVIS answer half a thought and then receive the rest as
* a separate question. Instead, collect the pieces and send once the speaker
* has actually stopped.
*/
const UTTERANCE_GAP_MS = 1000;
// A pause never arrives while someone is dictating steadily, so cap the wait
// rather than let a long sentence hold the whole conversation open.
const UTTERANCE_MAX_MS = 6000;

let pendingFragments: string[] = [];
let gapTimer: number | undefined;
let maxTimer: number | undefined;

function discardUtterance() {
clearTimeout(gapTimer);
clearTimeout(maxTimer);
gapTimer = undefined;
maxTimer = undefined;
pendingFragments = [];
}

function flushUtterance() {
clearTimeout(gapTimer);
clearTimeout(maxTimer);
gapTimer = undefined;
maxTimer = undefined;

const text = pendingFragments.join(" ").replace(/\s+/g, " ").trim();
pendingFragments = [];
if (!text) return;

socket.send({ type: "transcript", text, isFinal: true });
transition("thinking");
}

const voiceInput = createVoiceInput(
(text: string) => {
// Cancel any current JARVIS response before sending new input
// Cancel any current JARVIS response before sending new input. Done on the
// first fragment, not at flush time: cutting him off is what the user
// wanted the moment they started talking.
audioPlayer.stop();
// User spoke — send transcript
socket.send({ type: "transcript", text, isFinal: true });
transition("thinking");

pendingFragments.push(text);
clearTimeout(gapTimer);
gapTimer = window.setTimeout(flushUtterance, UTTERANCE_GAP_MS);
if (maxTimer === undefined) {
maxTimer = window.setTimeout(flushUtterance, UTTERANCE_MAX_MS);
}
},
(msg: string) => {
showError(msg);
Expand Down Expand Up @@ -123,6 +192,7 @@ socket.onMessage((msg) => {
}
// Log text for debugging
if (msg.text) console.log("[JARVIS]", msg.text);
showCaption(msg.text);
} else if (type === "status") {
const state = msg.state as string;
if (state === "thinking" && currentState !== "thinking") {
Expand All @@ -137,6 +207,7 @@ socket.onMessage((msg) => {
} else if (type === "text") {
// Text fallback when TTS fails
console.log("[JARVIS]", msg.text);
showCaption(msg.text);
} else if (type === "task_spawned") {
console.log("[task]", "spawned:", msg.task_id, msg.prompt);
} else if (type === "task_complete") {
Expand All @@ -148,12 +219,40 @@ socket.onMessage((msg) => {
// Kick off
// ---------------------------------------------------------------------------

/**
* Read the speech language chosen in the settings panel.
*
* Resolved before the microphone opens so the first session is built in the
* right language: switching a live session means tearing it down and racing
* for the microphone, which is worth avoiding on every page load. Bounded, so
* an unreachable server costs a moment rather than the microphone.
*/
async function fetchSpeechLanguage(): Promise<string | null> {
try {
const res = await fetch("/api/settings/preferences", {
signal: AbortSignal.timeout(2000),
});
const prefs = (await res.json()) as { speech_lang?: string };
return prefs.speech_lang || null;
} catch {
return null; // Server not ready — keep the default language.
}
}

// Start listening after a brief delay for the orb to render
setTimeout(() => {
setTimeout(async () => {
const lang = await fetchSpeechLanguage();
if (lang) voiceInput.setLanguage(lang);
voiceInput.start();
transition("listening");
}, 1000);

// Saving the setting re-tunes the running session — no reload needed.
document.addEventListener(SPEECH_LANG_EVENT, (e) => {
const lang = (e as CustomEvent<string>).detail;
if (lang) voiceInput.setLanguage(lang);
});

// Resume AudioContext on ANY user interaction (browser autoplay policy)
function ensureAudioContext() {
const ctx = audioPlayer.getAnalyser().context as AudioContext;
Expand Down Expand Up @@ -183,6 +282,8 @@ btnMute.addEventListener("click", (e) => {
isMuted = !isMuted;
btnMute.classList.toggle("muted", isMuted);
if (isMuted) {
// Half-spoken words must not arrive a second after the user silenced him.
discardUtterance();
voiceInput.pause();
transition("idle");
} else {
Expand Down
Loading