- Reset master to upstream/main (16,697 commits) - Overlay 2,271 local-only files (skills, tools, workspace, configs, apps) - Restore IDENTITY.md and USER.md templates - Build verified, gateway running, Discord working Co-Authored-By: Claude Opus 4.6 <[email protected]>
753 lines
26 KiB
HTML
753 lines
26 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>J.A.R.V.I.S</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
body {
|
|
background: #020c18;
|
|
color: #7dd6f5;
|
|
font-family: 'Courier New', monospace;
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Ambient glow bg */
|
|
body::before {
|
|
content: '';
|
|
position: fixed;
|
|
inset: 0;
|
|
background: radial-gradient(ellipse at 50% 50%, rgba(0,120,200,0.08) 0%, transparent 70%);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.title {
|
|
letter-spacing: 0.4em;
|
|
font-size: 13px;
|
|
opacity: 0.6;
|
|
margin-bottom: 32px;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
/* ---- Orb ---- */
|
|
.orb-wrap {
|
|
position: relative;
|
|
width: 260px;
|
|
height: 260px;
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.ring {
|
|
position: absolute;
|
|
inset: 0;
|
|
border-radius: 50%;
|
|
border: 1px solid rgba(0, 180, 255, 0.25);
|
|
animation: spin 8s linear infinite;
|
|
}
|
|
.ring:nth-child(2) { inset: 14px; border-color: rgba(0,200,255,0.3); animation-duration: 6s; animation-direction: reverse; }
|
|
.ring:nth-child(3) { inset: 28px; border-color: rgba(0,220,255,0.35); animation-duration: 4s; }
|
|
.ring:nth-child(4) { inset: 42px; border-color: rgba(0,200,255,0.2); animation-duration: 12s; animation-direction: reverse; }
|
|
|
|
/* Tick marks on ring 1 */
|
|
.ring::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: -3px; left: 50%;
|
|
width: 6px; height: 6px;
|
|
background: #00d4ff;
|
|
border-radius: 50%;
|
|
box-shadow: 0 0 8px #00d4ff;
|
|
transform: translateX(-50%);
|
|
}
|
|
|
|
.orb-core {
|
|
position: absolute;
|
|
inset: 56px;
|
|
border-radius: 50%;
|
|
background: radial-gradient(circle at 35% 35%, rgba(0,180,255,0.5), rgba(0,80,160,0.3) 60%, rgba(0,20,60,0.8));
|
|
box-shadow:
|
|
0 0 30px rgba(0,180,255,0.4),
|
|
0 0 60px rgba(0,100,200,0.2),
|
|
inset 0 0 20px rgba(0,200,255,0.15);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.orb-hex {
|
|
width: 54px;
|
|
height: 54px;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
/* State animations */
|
|
.orb-wrap.listening .ring {
|
|
border-color: rgba(0, 230, 255, 0.5);
|
|
box-shadow: 0 0 12px rgba(0,200,255,0.2);
|
|
}
|
|
.orb-wrap.listening .orb-core {
|
|
box-shadow:
|
|
0 0 40px rgba(0,220,255,0.6),
|
|
0 0 80px rgba(0,150,220,0.3),
|
|
inset 0 0 24px rgba(0,220,255,0.3);
|
|
animation: pulse 1s ease-in-out infinite;
|
|
}
|
|
.orb-wrap.thinking .ring { animation-duration: 1.5s !important; }
|
|
.orb-wrap.thinking .orb-core { animation: throb 0.6s ease-in-out infinite alternate; }
|
|
.orb-wrap.speaking .orb-core { animation: speak 0.4s ease-in-out infinite alternate; }
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
@keyframes pulse {
|
|
0%, 100% { box-shadow: 0 0 40px rgba(0,220,255,0.6), 0 0 80px rgba(0,150,220,0.3), inset 0 0 24px rgba(0,220,255,0.3); }
|
|
50% { box-shadow: 0 0 60px rgba(0,220,255,0.9), 0 0 120px rgba(0,150,220,0.5), inset 0 0 32px rgba(0,220,255,0.5); }
|
|
}
|
|
@keyframes throb {
|
|
from { opacity: 0.7; } to { opacity: 1; }
|
|
}
|
|
@keyframes speak {
|
|
from { box-shadow: 0 0 40px rgba(0,255,200,0.5), 0 0 80px rgba(0,180,150,0.2), inset 0 0 20px rgba(0,255,200,0.2); }
|
|
to { box-shadow: 0 0 70px rgba(0,255,200,0.8), 0 0 130px rgba(0,200,160,0.4), inset 0 0 32px rgba(0,255,200,0.4); }
|
|
}
|
|
|
|
/* ---- Volume bar ---- */
|
|
.vol-bars {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: 3px;
|
|
height: 24px;
|
|
margin-bottom: 20px;
|
|
opacity: 0;
|
|
transition: opacity 0.3s;
|
|
}
|
|
.vol-bars.active { opacity: 1; }
|
|
.vol-bar {
|
|
width: 4px;
|
|
background: #00d4ff;
|
|
border-radius: 2px;
|
|
transition: height 0.08s;
|
|
min-height: 2px;
|
|
box-shadow: 0 0 4px #00d4ff;
|
|
}
|
|
|
|
/* ---- Status ---- */
|
|
.status-label {
|
|
font-size: 11px;
|
|
letter-spacing: 0.3em;
|
|
text-transform: uppercase;
|
|
opacity: 0.5;
|
|
margin-bottom: 4px;
|
|
}
|
|
.status-text {
|
|
font-size: 14px;
|
|
letter-spacing: 0.15em;
|
|
min-height: 20px;
|
|
text-align: center;
|
|
color: #a0e8ff;
|
|
}
|
|
|
|
/* ---- Transcript ---- */
|
|
.conversation {
|
|
width: min(520px, calc(100vw - 40px));
|
|
margin-top: 32px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
.bubble {
|
|
padding: 12px 16px;
|
|
border-radius: 10px;
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
display: none;
|
|
border: 1px solid rgba(0,180,255,0.2);
|
|
}
|
|
.bubble.you {
|
|
background: rgba(0,60,120,0.3);
|
|
color: #7dd6f5;
|
|
text-align: right;
|
|
}
|
|
.bubble.jarvis {
|
|
background: rgba(0,20,60,0.5);
|
|
color: #c0f0ff;
|
|
}
|
|
.bubble-label {
|
|
font-size: 10px;
|
|
letter-spacing: 0.2em;
|
|
text-transform: uppercase;
|
|
opacity: 0.5;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
/* ---- VAD threshold slider ---- */
|
|
.controls {
|
|
margin-top: 24px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
opacity: 0.4;
|
|
font-size: 11px;
|
|
letter-spacing: 0.1em;
|
|
}
|
|
.controls:hover { opacity: 0.8; }
|
|
input[type=range] { width: 80px; accent-color: #00d4ff; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="title">J · A · R · V · I · S</div>
|
|
|
|
<div class="orb-wrap" id="orb">
|
|
<div class="ring"></div>
|
|
<div class="ring"></div>
|
|
<div class="ring"></div>
|
|
<div class="ring"></div>
|
|
<div class="orb-core">
|
|
<!-- Hexagon SVG -->
|
|
<svg class="orb-hex" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<polygon points="30,4 54,17 54,43 30,56 6,43 6,17" stroke="#00d4ff" stroke-width="1.5" fill="rgba(0,180,255,0.08)"/>
|
|
<polygon points="30,12 46,21 46,39 30,48 14,39 14,21" stroke="#00d4ff" stroke-width="1" fill="rgba(0,180,255,0.05)" opacity="0.6"/>
|
|
<circle cx="30" cy="30" r="6" fill="#00d4ff" opacity="0.8"/>
|
|
<circle cx="30" cy="30" r="10" stroke="#00d4ff" stroke-width="0.5" opacity="0.3"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Volume visualiser -->
|
|
<div class="vol-bars" id="volBars">
|
|
<div class="vol-bar" id="b0"></div>
|
|
<div class="vol-bar" id="b1"></div>
|
|
<div class="vol-bar" id="b2"></div>
|
|
<div class="vol-bar" id="b3"></div>
|
|
<div class="vol-bar" id="b4"></div>
|
|
<div class="vol-bar" id="b5"></div>
|
|
<div class="vol-bar" id="b6"></div>
|
|
</div>
|
|
|
|
<div class="status-label">STATUS</div>
|
|
<div class="status-text" id="statusText">INITIALISING</div>
|
|
|
|
<div class="conversation">
|
|
<div class="bubble you" id="youBubble">
|
|
<div class="bubble-label">You</div>
|
|
<div id="youText"></div>
|
|
</div>
|
|
<div class="bubble jarvis" id="jarvisBubble">
|
|
<div class="bubble-label">Jarvis</div>
|
|
<div id="jarvisText"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- mic level readout -->
|
|
<div id="micLevel" style="font-size:11px;opacity:0.5;margin-top:4px;letter-spacing:0.15em;">MIC: —</div>
|
|
|
|
<!-- Wispr Flow / type input -->
|
|
<div style="margin-top:16px;display:flex;gap:8px;width:100%;max-width:480px;">
|
|
<input type="text" id="textInput"
|
|
placeholder="Dikter via Wispr Flow eller skriv her…"
|
|
style="flex:1;background:rgba(0,180,255,0.06);border:1px solid rgba(0,180,255,0.25);
|
|
border-radius:6px;padding:8px 12px;color:#7dd6f5;font-family:'Courier New',monospace;
|
|
font-size:13px;outline:none;letter-spacing:0.05em;"
|
|
autocomplete="off" autocorrect="off" spellcheck="false">
|
|
<button id="textSendBtn"
|
|
style="background:rgba(0,180,255,0.15);border:1px solid rgba(0,180,255,0.4);
|
|
border-radius:6px;padding:8px 14px;color:#00d4ff;font-family:'Courier New',monospace;
|
|
font-size:12px;cursor:pointer;letter-spacing:0.1em;">
|
|
SEND
|
|
</button>
|
|
</div>
|
|
|
|
<div class="controls">
|
|
<span>MIC SENSITIVITY</span>
|
|
<input type="range" id="thresh" min="1" max="30" value="5">
|
|
<span id="threshVal">5</span>
|
|
<label style="margin-left:12px;font-size:11px;opacity:0.7;">Use Porcupine</label>
|
|
<input type="checkbox" id="usePorcupine" style="margin-left:6px;" title="Enable client-side Porcupine wake-word (requires model in /porcupine)">
|
|
</div>
|
|
|
|
<script>
|
|
const token = new URLSearchParams(location.search).get('token');
|
|
if (!token) {
|
|
document.getElementById('statusText').textContent = 'MISSING TOKEN';
|
|
throw new Error('Missing token');
|
|
}
|
|
|
|
const orb = document.getElementById('orb');
|
|
const statusText = document.getElementById('statusText');
|
|
const volBars = document.getElementById('volBars');
|
|
const bars = [0,1,2,3,4,5,6].map(i => document.getElementById('b'+i));
|
|
const youBubble = document.getElementById('youBubble');
|
|
const youText = document.getElementById('youText');
|
|
const jarvisBubble = document.getElementById('jarvisBubble');
|
|
const jarvisText = document.getElementById('jarvisText');
|
|
const threshSlider = document.getElementById('thresh');
|
|
const threshVal = document.getElementById('threshVal');
|
|
const usePorcupineCheckbox = document.getElementById('usePorcupine');
|
|
|
|
// States: 'standby' | 'wake_check' | 'active' | 'processing'
|
|
// standby — always listening for wake word via VAD + whisper
|
|
// wake_check — short clip sent to server to check for "james"
|
|
// active — wake word confirmed, next speech is the command
|
|
// processing — agent running / audio playing
|
|
let state = 'standby';
|
|
let activeTimer = null; // auto-return to standby if no speech within 8s
|
|
|
|
let ws, mediaRecorder, audioCtx, analyser, source, vadInterval;
|
|
let chunks = [], recording = false, busy = false;
|
|
let silenceTimer = null;
|
|
let THRESH = 5;
|
|
let SILENCE_MS = 1200;
|
|
|
|
// Porcupine related
|
|
let porcupineActive = false;
|
|
let porcupineNode = null; // ScriptProcessor/AudioWorklet node used for detection
|
|
let porcupineReady = false;
|
|
|
|
// Audio streaming — collect binary WS chunks between audio-start/audio-end
|
|
let audioChunks = [];
|
|
let collectingAudio = false;
|
|
let audioMimeType = 'audio/mpeg';
|
|
|
|
threshSlider.addEventListener('input', () => {
|
|
THRESH = Number(threshSlider.value);
|
|
threshVal.textContent = THRESH;
|
|
});
|
|
|
|
usePorcupineCheckbox.addEventListener('change', async (e) => {
|
|
if (e.target.checked) {
|
|
setStatus('Loading Porcupine…');
|
|
try {
|
|
await loadPorcupineIfAvailable();
|
|
if (porcupineReady) setStatus('Porcupine ready — listening');
|
|
else setStatus('Listening for wake word...');
|
|
} catch (err) {
|
|
console.warn('Porcupine load failed', err);
|
|
setStatus('Listening for wake word...');
|
|
usePorcupineCheckbox.checked = false;
|
|
}
|
|
} else {
|
|
stopPorcupine();
|
|
setStatus('Porcupine disabled');
|
|
}
|
|
});
|
|
|
|
function setOrb(state) {
|
|
orb.className = 'orb-wrap ' + (state || '');
|
|
}
|
|
|
|
function setStatus(s) {
|
|
statusText.textContent = s.toUpperCase();
|
|
}
|
|
|
|
const micLevelEl = document.getElementById('micLevel');
|
|
|
|
// ---- WebSocket ----
|
|
function connect() {
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
ws = new WebSocket(`${proto}//${location.host}/?token=${token}`);
|
|
ws.binaryType = 'arraybuffer'; // receive audio chunks as ArrayBuffer
|
|
|
|
ws.onopen = () => {
|
|
startVAD();
|
|
setStatus('STANDBY — say "James"');
|
|
};
|
|
ws.onclose = () => {
|
|
setStatus('Reconnecting…');
|
|
setOrb('');
|
|
stopVAD();
|
|
setTimeout(connect, 1500);
|
|
};
|
|
ws.onmessage = async (ev) => {
|
|
// Binary frame = audio chunk from ElevenLabs stream
|
|
if (ev.data instanceof ArrayBuffer) {
|
|
if (collectingAudio) audioChunks.push(ev.data);
|
|
return;
|
|
}
|
|
|
|
const msg = JSON.parse(ev.data);
|
|
|
|
if (msg.type === 'audio-start') {
|
|
collectingAudio = true;
|
|
audioChunks = [];
|
|
audioMimeType = msg.mimeType || 'audio/mpeg';
|
|
setOrb('speaking');
|
|
setStatus('Speaking');
|
|
return;
|
|
}
|
|
|
|
if (msg.type === 'audio-end') {
|
|
collectingAudio = false;
|
|
const blob = new Blob(audioChunks, { type: audioMimeType });
|
|
audioChunks = [];
|
|
const url = URL.createObjectURL(blob);
|
|
const audio = new Audio(url);
|
|
// play() returns a Promise — catch NotAllowedError on mobile if no user gesture
|
|
audio.play().catch(() => {
|
|
// Fallback: user must tap to play
|
|
setStatus('TAP ORB TO PLAY REPLY');
|
|
document.getElementById('orb').addEventListener('click', () => {
|
|
audio.play();
|
|
}, { once: true });
|
|
});
|
|
audio.onended = () => {
|
|
URL.revokeObjectURL(url);
|
|
setOrb('');
|
|
setStatus('Ready — listening');
|
|
busy = false;
|
|
};
|
|
return;
|
|
}
|
|
|
|
if (msg.type === 'wake-result') {
|
|
if (msg.detected) {
|
|
// Wake word confirmed — play a soft chime and enter active mode
|
|
playChime();
|
|
if (msg.command && msg.command.length > 2) {
|
|
// Command was spoken in same breath — process it directly
|
|
setStatus('Heard: ' + msg.command);
|
|
youBubble.style.display = 'block';
|
|
youText.textContent = msg.command;
|
|
enterProcessing(msg.command);
|
|
} else {
|
|
enterActive();
|
|
}
|
|
} else {
|
|
// Not a wake word — back to standby
|
|
busy = false;
|
|
state = 'standby';
|
|
setOrb('');
|
|
setStatus('STANDBY — say "James"');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (msg.type === 'status') {
|
|
setStatus(msg.message);
|
|
if (msg.message.toLowerCase().includes('thinking')) setOrb('thinking');
|
|
if (msg.message.toLowerCase().includes('speaking')) setOrb('speaking');
|
|
if (msg.message.toLowerCase().includes('ready')) {
|
|
setOrb('');
|
|
state = 'standby';
|
|
busy = false;
|
|
setStatus('STANDBY — say "James"');
|
|
}
|
|
}
|
|
if (msg.type === 'transcript') {
|
|
youBubble.style.display = 'block';
|
|
youText.textContent = msg.text;
|
|
}
|
|
if (msg.type === 'reply') {
|
|
jarvisBubble.style.display = 'block';
|
|
jarvisText.textContent = msg.text;
|
|
}
|
|
if (msg.type === 'error') {
|
|
setStatus('Error: ' + msg.message);
|
|
setOrb('');
|
|
state = 'standby';
|
|
busy = false;
|
|
}
|
|
};
|
|
}
|
|
|
|
// ---- Porcupine integration (client-side wake word) ----
|
|
// This will attempt to load a local Porcupine bundle from /porcupine/
|
|
// Place a porcupine JS/WASM + the .ppn keyword model in public/porcupine/
|
|
// Example expected files:
|
|
// - /porcupine/porcupine.js (worker or UMD build that exposes `Porcupine`)
|
|
// - /porcupine/jarvis.ppn (keyword model)
|
|
async function loadPorcupineIfAvailable() {
|
|
if (porcupineReady) return true;
|
|
try {
|
|
// Try to load a local script that provides a global `Porcupine` object.
|
|
await new Promise((resolve, reject) => {
|
|
const s = document.createElement('script');
|
|
s.src = '/porcupine/porcupine.js';
|
|
s.onload = resolve; s.onerror = reject; s.async = true;
|
|
document.head.appendChild(s);
|
|
});
|
|
|
|
if (!window.Porcupine) {
|
|
console.warn('porcupine.js loaded but global Porcupine not found');
|
|
return false;
|
|
}
|
|
|
|
// create audio context + detection node
|
|
if (!audioCtx) audioCtx = new AudioContext();
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
const mic = audioCtx.createMediaStreamSource(stream);
|
|
|
|
// ScriptProcessor fallback for browsers without AudioWorklet
|
|
const proc = audioCtx.createScriptProcessor(4096, 1, 1);
|
|
proc.onaudioprocess = (ev) => {
|
|
if (!porcupineReady) return;
|
|
const input = ev.inputBuffer.getChannelData(0);
|
|
// convert float32 to 16-bit PCM
|
|
const int16 = new Int16Array(input.length);
|
|
for (let i = 0; i < input.length; i++) {
|
|
let s = Math.max(-1, Math.min(1, input[i]));
|
|
int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
|
}
|
|
try {
|
|
const r = window.Porcupine && window.Porcupine.process && window.Porcupine.process(int16);
|
|
if (r === 0 || r === 1 || r > -1) {
|
|
// detection (API may return index or truthy). Trigger wake.
|
|
playChime();
|
|
enterActive();
|
|
}
|
|
} catch (err) {
|
|
// library specific errors; ignore and fallback
|
|
}
|
|
};
|
|
|
|
mic.connect(proc);
|
|
proc.connect(audioCtx.destination);
|
|
|
|
porcupineNode = proc;
|
|
porcupineReady = true;
|
|
porcupineActive = true;
|
|
return true;
|
|
} catch (err) {
|
|
console.warn('Porcupine load failed', err);
|
|
porcupineReady = false;
|
|
porcupineActive = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function stopPorcupine() {
|
|
try {
|
|
if (porcupineNode) {
|
|
porcupineNode.disconnect();
|
|
porcupineNode = null;
|
|
}
|
|
porcupineReady = false;
|
|
porcupineActive = false;
|
|
} catch (e) {}
|
|
}
|
|
|
|
// ---- VAD via Web Audio API ----
|
|
async function startVAD() {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000 }
|
|
});
|
|
|
|
audioCtx = new AudioContext();
|
|
analyser = audioCtx.createAnalyser();
|
|
analyser.fftSize = 256;
|
|
source = audioCtx.createMediaStreamSource(stream);
|
|
source.connect(analyser);
|
|
|
|
// If Porcupine selected and available, start its detection chain as well
|
|
if (usePorcupineCheckbox.checked) {
|
|
const ok = await loadPorcupineIfAvailable();
|
|
if (ok) {
|
|
setStatus('Porcupine active — say "James"');
|
|
// Keep VAD running for UI only
|
|
} else {
|
|
setStatus('Porcupine not available — using VAD');
|
|
}
|
|
}
|
|
|
|
const dataArr = new Uint8Array(analyser.frequencyBinCount);
|
|
|
|
// MediaRecorder on same stream
|
|
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
|
|
mediaRecorder.ondataavailable = e => { if (e.data?.size > 0) chunks.push(e.data); };
|
|
mediaRecorder.onstop = async () => {
|
|
if (busy) return;
|
|
busy = true;
|
|
const blob = new Blob(chunks, { type: 'audio/webm' });
|
|
chunks = [];
|
|
const buf = await blob.arrayBuffer();
|
|
|
|
if (state === 'standby') {
|
|
// Short clip — check for wake word on server side (fallback)
|
|
if (buf.byteLength < 512) { busy = false; return; }
|
|
state = 'wake_check';
|
|
setOrb('');
|
|
setStatus('…');
|
|
ws.send(buf);
|
|
ws.send(JSON.stringify({ type: 'wake-check' }));
|
|
} else if (state === 'active') {
|
|
// Full command
|
|
clearTimeout(activeTimer);
|
|
if (buf.byteLength < 1024) { busy = false; enterActive(); return; }
|
|
state = 'processing';
|
|
setOrb('thinking');
|
|
setStatus('Transcribing…');
|
|
ws.send(buf);
|
|
ws.send(JSON.stringify({ type: 'audio-end' }));
|
|
} else {
|
|
busy = false;
|
|
}
|
|
};
|
|
|
|
// Animate bars + state machine VAD
|
|
vadInterval = setInterval(() => {
|
|
analyser.getByteFrequencyData(dataArr);
|
|
|
|
// RMS of first 80 bins (voice range)
|
|
let sum = 0;
|
|
for (let i = 2; i < 80; i++) sum += dataArr[i] ** 2;
|
|
const rms = Math.sqrt(sum / 78);
|
|
|
|
// Animate bars
|
|
for (let i = 0; i < 7; i++) {
|
|
const bin = Math.floor(2 + i * 10);
|
|
const h = Math.round((dataArr[bin] / 255) * 24);
|
|
bars[i].style.height = Math.max(2, h) + 'px';
|
|
}
|
|
|
|
micLevelEl.textContent = `MIC: ${Math.round(rms)} / ${THRESH}`;
|
|
|
|
// If porcupineActive, we let porcupine trigger enterActive(); otherwise use VAD recording triggers
|
|
const speaking = rms > THRESH;
|
|
const canRecord = !recording && !busy && (state === 'standby' || state === 'active');
|
|
|
|
if (speaking && canRecord && !porcupineActive) {
|
|
recording = true;
|
|
chunks = [];
|
|
clearTimeout(silenceTimer);
|
|
silenceTimer = null;
|
|
mediaRecorder.start();
|
|
setOrb('listening');
|
|
volBars.classList.add('active');
|
|
if (state === 'standby') setStatus('Hearing…');
|
|
else setStatus('Listening…');
|
|
}
|
|
|
|
if (recording && !porcupineActive) {
|
|
if (!speaking) {
|
|
if (!silenceTimer) {
|
|
silenceTimer = setTimeout(() => {
|
|
if (!recording) return;
|
|
recording = false;
|
|
silenceTimer = null;
|
|
volBars.classList.remove('active');
|
|
if (mediaRecorder.state === 'recording') mediaRecorder.stop();
|
|
// onstop will fire → sends correct msg type based on current state
|
|
}, SILENCE_MS);
|
|
}
|
|
} else {
|
|
clearTimeout(silenceTimer);
|
|
silenceTimer = null;
|
|
}
|
|
}
|
|
}, 80);
|
|
|
|
} catch (err) {
|
|
setStatus('Mic error: ' + err.message);
|
|
}
|
|
}
|
|
|
|
function stopVAD() {
|
|
clearInterval(vadInterval);
|
|
clearTimeout(silenceTimer);
|
|
if (audioCtx) audioCtx.close();
|
|
}
|
|
|
|
// ---- State helpers ----
|
|
function enterActive() {
|
|
state = 'active';
|
|
busy = false;
|
|
setOrb('listening');
|
|
setStatus('YES SIR — SPEAK NOW');
|
|
clearTimeout(activeTimer);
|
|
// Auto-return to standby after 8s of silence
|
|
activeTimer = setTimeout(() => {
|
|
if (state === 'active' && !recording) {
|
|
state = 'standby';
|
|
setOrb('');
|
|
setStatus('STANDBY — say "James"');
|
|
}
|
|
}, 8000);
|
|
}
|
|
|
|
function enterProcessing(command) {
|
|
state = 'processing';
|
|
busy = true;
|
|
setOrb('thinking');
|
|
setStatus('Thinking…');
|
|
// Send command text directly (already transcribed inline with wake word)
|
|
ws.send(new ArrayBuffer(0)); // empty binary (server needs it before audio-end)
|
|
ws.send(JSON.stringify({ type: 'audio-end', text: command }));
|
|
}
|
|
|
|
// Soft chime on wake detection (AudioContext beep — no file needed)
|
|
function playChime() {
|
|
try {
|
|
const ctx = new AudioContext();
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.connect(gain); gain.connect(ctx.destination);
|
|
osc.frequency.setValueAtTime(880, ctx.currentTime);
|
|
osc.frequency.exponentialRampToValueAtTime(1320, ctx.currentTime + 0.1);
|
|
gain.gain.setValueAtTime(0.15, ctx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
|
|
osc.start(); osc.stop(ctx.currentTime + 0.3);
|
|
} catch(e) {}
|
|
}
|
|
|
|
// ── Wispr Flow / text input ──────────────────────────────────────
|
|
const textInput = document.getElementById('textInput');
|
|
const textSendBtn = document.getElementById('textSendBtn');
|
|
let textAutoTimer = null;
|
|
|
|
function sendTextCommand() {
|
|
const cmd = textInput.value.trim();
|
|
if (!cmd || state === 'processing') return;
|
|
textInput.value = '';
|
|
clearTimeout(textAutoTimer);
|
|
youText.textContent = cmd;
|
|
youBubble.style.display = 'block';
|
|
jarvisBubble.style.display = 'none';
|
|
enterProcessing(cmd);
|
|
}
|
|
|
|
// Auto-send 1.5s after last keystroke (Wispr Flow pastes fast, then stops)
|
|
textInput.addEventListener('input', () => {
|
|
clearTimeout(textAutoTimer);
|
|
if (textInput.value.trim()) {
|
|
textAutoTimer = setTimeout(sendTextCommand, 1500);
|
|
}
|
|
});
|
|
|
|
// Enter key sends immediately
|
|
textInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') { e.preventDefault(); sendTextCommand(); }
|
|
});
|
|
|
|
textSendBtn.addEventListener('click', sendTextCommand);
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
// Tap orb = manual wake (fallback)
|
|
document.getElementById('orb').addEventListener('click', () => {
|
|
if (state === 'processing') return;
|
|
if (state === 'active') {
|
|
clearTimeout(activeTimer);
|
|
state = 'standby';
|
|
busy = false;
|
|
setOrb('');
|
|
setStatus('STANDBY — say "James"');
|
|
return;
|
|
}
|
|
playChime();
|
|
enterActive();
|
|
});
|
|
|
|
connect();
|
|
|
|
// Helpful dev note in console
|
|
console.log('Wake-word: client-side Porcupine integration scaffolded. To enable: place porcupine JS/WASM and jarvis.ppn in public/porcupine/ and toggle "Use Porcupine". Otherwise the page falls back to VAD + server-side wake-check.');
|
|
</script>
|
|
</body>
|
|
</html>
|