mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-24 15:48:13 +02:00
feat!: rebuild EveFlow as a JARVIS-style voice HUD for Hermes Agent (v2.0.0)
Complete rewrite of the application: - Toolchain: Electron 44, Vite 8, React 19, TypeScript 5.9, Zustand, Vitest; main process rewritten in TypeScript (electron/), typed IPC contract (shared/), sandboxed renderer with webSecurity on and an HTTP/SSE proxy in the main process. - Voice: AudioWorklet microphone capture at 16 kHz with adaptive energy VAD and auto-stop, WAV encoder, OpenAI-compatible STT, TTS queue with sentence-level streaming, prefetch and Web Audio playback feeding an analyser; hands-free mode, global push-to-talk hotkey, system-voice fallback. - Hermes: full API client (capabilities, health, models, skills, toolsets, sessions, jobs) with automatic transport selection: runs API (SSE lifecycle, approvals, steer, stop) > sessions stream > chat completions with local tools; tolerant event normalisation; webhook receiver hardened (secret, size limit). - UI: JARVIS arc-reactor core on Canvas 2D reacting to the real audio signal, holographic HUD layout (transcript, core, Hermes ops panel with tools/crons/ skills/sessions/link tabs, telemetry), settings drawer, approval modals, compact floating widget, four themes, tray icon and global shortcuts. - Removed the 3D Eve robot, three.js and legacy assets; migrated 1.x settings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
This commit is contained in:
239 files changed
+11351
-86495
No files matched your search
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Microphone capture built on AudioWorklet (no deprecated ScriptProcessor, no mic monitoring
|
||||
* feedback). Produces 16 kHz WAV utterances, with energy VAD for hands-free auto stop.
|
||||
*/
|
||||
import { Log } from '../../lib/log';
|
||||
import { audioBus } from './audioBus';
|
||||
import { DEFAULT_VAD, EnergyVad, type VadOptions, type VadSignal } from './vad';
|
||||
import { buildWav16k, rms, type WavResult } from './wav';
|
||||
|
||||
const WORKLET_SOURCE = `
|
||||
class EveFlowCaptureProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.buffer = new Float32Array(2048);
|
||||
this.offset = 0;
|
||||
}
|
||||
process(inputs) {
|
||||
const input = inputs[0];
|
||||
if (!input || input.length === 0) return true;
|
||||
const channel = input[0];
|
||||
if (!channel) return true;
|
||||
let i = 0;
|
||||
while (i < channel.length) {
|
||||
const n = Math.min(channel.length - i, this.buffer.length - this.offset);
|
||||
this.buffer.set(channel.subarray(i, i + n), this.offset);
|
||||
this.offset += n;
|
||||
i += n;
|
||||
if (this.offset === this.buffer.length) {
|
||||
this.port.postMessage(this.buffer, [this.buffer.buffer]);
|
||||
this.buffer = new Float32Array(2048);
|
||||
this.offset = 0;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('eveflow-capture', EveFlowCaptureProcessor);
|
||||
`;
|
||||
|
||||
export type CaptureMode = 'auto' | 'manual';
|
||||
|
||||
export interface CaptureCallbacks {
|
||||
onLevel?: (level: number) => void;
|
||||
onSpeechStart?: () => void;
|
||||
onUtterance: (wav: WavResult) => void;
|
||||
onEnd: (reason: 'stopped' | 'speech-end' | 'no-speech' | 'too-short' | 'max-length' | 'error') => void;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface CaptureOptions {
|
||||
deviceId?: string;
|
||||
mode: CaptureMode;
|
||||
vad?: Partial<VadOptions>;
|
||||
callbacks: CaptureCallbacks;
|
||||
}
|
||||
|
||||
export interface MicDevice {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
let workletUrl: string | null = null;
|
||||
|
||||
function getWorkletUrl(): string {
|
||||
if (!workletUrl) workletUrl = URL.createObjectURL(new Blob([WORKLET_SOURCE], { type: 'application/javascript' }));
|
||||
return workletUrl;
|
||||
}
|
||||
|
||||
export async function listMicrophones(): Promise<MicDevice[]> {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
return devices
|
||||
.filter((d) => d.kind === 'audioinput')
|
||||
.map((d, i) => ({ deviceId: d.deviceId, label: d.label || `Microphone ${i + 1}` }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export class MicCapture {
|
||||
private ctx: AudioContext | null = null;
|
||||
private stream: MediaStream | null = null;
|
||||
private worklet: AudioWorkletNode | null = null;
|
||||
private legacy: ScriptProcessorNode | null = null;
|
||||
private chunks: Float32Array[] = [];
|
||||
private totalSamples = 0;
|
||||
private vad: EnergyVad | null = null;
|
||||
private active = false;
|
||||
private options: CaptureOptions | null = null;
|
||||
private sampleRate = 16_000;
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
async start(options: CaptureOptions): Promise<void> {
|
||||
if (this.active) this.stop('stopped');
|
||||
this.options = options;
|
||||
this.chunks = [];
|
||||
this.totalSamples = 0;
|
||||
this.vad = options.mode === 'auto' ? new EnergyVad(options.vad ?? DEFAULT_VAD) : null;
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error("Le microphone n'est pas accessible dans cet environnement (getUserMedia indisponible).");
|
||||
}
|
||||
|
||||
const constraints: MediaStreamConstraints = {
|
||||
audio: {
|
||||
deviceId: options.deviceId ? { exact: options.deviceId } : undefined,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
channelCount: 1
|
||||
}
|
||||
};
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
} catch (err) {
|
||||
const e = err as DOMException;
|
||||
const detail =
|
||||
e.name === 'NotAllowedError'
|
||||
? "Accès au micro refusé. Vérifiez les paramètres de confidentialité Windows (Microphone) et relancez EveFlow."
|
||||
: e.name === 'NotFoundError'
|
||||
? 'Aucun microphone détecté.'
|
||||
: e.name === 'NotReadableError'
|
||||
? 'Le microphone est utilisé par une autre application.'
|
||||
: `${e.name}: ${e.message}`;
|
||||
Log.error('mic', 'getUserMedia failed', { name: e.name, message: e.message });
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
try {
|
||||
this.ctx = new AudioContext({ sampleRate: 16_000, latencyHint: 'interactive' });
|
||||
} catch {
|
||||
this.ctx = new AudioContext({ latencyHint: 'interactive' });
|
||||
}
|
||||
if (this.ctx.state === 'suspended') await this.ctx.resume().catch(() => undefined);
|
||||
this.sampleRate = this.ctx.sampleRate;
|
||||
|
||||
const source = this.ctx.createMediaStreamSource(this.stream);
|
||||
const analyser = this.ctx.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
analyser.smoothingTimeConstant = 0.5;
|
||||
source.connect(analyser);
|
||||
audioBus.setInputAnalyser(analyser);
|
||||
|
||||
try {
|
||||
await this.ctx.audioWorklet.addModule(getWorkletUrl());
|
||||
this.worklet = new AudioWorkletNode(this.ctx, 'eveflow-capture', { numberOfInputs: 1, numberOfOutputs: 0, channelCount: 1 });
|
||||
this.worklet.port.onmessage = (event: MessageEvent<Float32Array>) => this.onSamples(event.data);
|
||||
source.connect(this.worklet);
|
||||
} catch (err) {
|
||||
Log.warn('mic', `AudioWorklet unavailable, falling back to ScriptProcessor: ${(err as Error).message}`);
|
||||
const processor = this.ctx.createScriptProcessor(2048, 1, 1);
|
||||
processor.onaudioprocess = (e) => this.onSamples(new Float32Array(e.inputBuffer.getChannelData(0)));
|
||||
const mute = this.ctx.createGain();
|
||||
mute.gain.value = 0; // Chrome requires a destination path, but we never want to hear the mic.
|
||||
source.connect(processor);
|
||||
processor.connect(mute);
|
||||
mute.connect(this.ctx.destination);
|
||||
this.legacy = processor;
|
||||
}
|
||||
|
||||
this.active = true;
|
||||
Log.info('mic', `capture started (${this.sampleRate} Hz, mode=${options.mode})`);
|
||||
}
|
||||
|
||||
private onSamples(samples: Float32Array): void {
|
||||
if (!this.active || !this.options) return;
|
||||
this.chunks.push(samples);
|
||||
this.totalSamples += samples.length;
|
||||
const level = rms(samples);
|
||||
this.options.callbacks.onLevel?.(Math.min(1, level * 6));
|
||||
|
||||
if (this.vad) {
|
||||
const frameMs = (samples.length / this.sampleRate) * 1000;
|
||||
const signal = this.vad.feed(level, frameMs);
|
||||
this.handleVad(signal);
|
||||
} else if (this.totalSamples / this.sampleRate > 120) {
|
||||
this.finishUtterance('max-length');
|
||||
}
|
||||
}
|
||||
|
||||
private handleVad(signal: VadSignal): void {
|
||||
switch (signal) {
|
||||
case 'speech-start':
|
||||
this.options?.callbacks.onSpeechStart?.();
|
||||
break;
|
||||
case 'speech-end':
|
||||
case 'max-length':
|
||||
this.finishUtterance(signal);
|
||||
break;
|
||||
case 'too-short':
|
||||
case 'no-speech':
|
||||
this.stop(signal);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private finishUtterance(reason: 'speech-end' | 'max-length' | 'stopped'): void {
|
||||
const opts = this.options;
|
||||
const chunks = this.chunks;
|
||||
const rate = this.sampleRate;
|
||||
this.teardown();
|
||||
if (!opts) return;
|
||||
if (chunks.length === 0) {
|
||||
opts.callbacks.onEnd('no-speech');
|
||||
return;
|
||||
}
|
||||
const wav = buildWav16k(chunks, rate);
|
||||
Log.info('mic', `utterance captured: ${wav.durationSec.toFixed(2)}s, ${wav.bytes.length} bytes`);
|
||||
if (wav.durationSec < 0.25) {
|
||||
opts.callbacks.onEnd('too-short');
|
||||
return;
|
||||
}
|
||||
opts.callbacks.onUtterance(wav);
|
||||
opts.callbacks.onEnd(reason);
|
||||
}
|
||||
|
||||
/** Stop capture. In manual mode this finalises the utterance; otherwise reports the reason. */
|
||||
stop(reason: 'stopped' | 'no-speech' | 'too-short' = 'stopped'): void {
|
||||
if (!this.active) return;
|
||||
if (reason === 'stopped') {
|
||||
this.finishUtterance('stopped');
|
||||
return;
|
||||
}
|
||||
const opts = this.options;
|
||||
this.teardown();
|
||||
opts?.callbacks.onEnd(reason);
|
||||
}
|
||||
|
||||
/** Abort without producing an utterance. */
|
||||
cancel(): void {
|
||||
if (!this.active) return;
|
||||
this.teardown();
|
||||
this.options?.callbacks.onEnd('stopped');
|
||||
}
|
||||
|
||||
private teardown(): void {
|
||||
this.active = false;
|
||||
audioBus.setInputAnalyser(null);
|
||||
if (this.worklet) {
|
||||
this.worklet.port.onmessage = null;
|
||||
this.worklet.disconnect();
|
||||
this.worklet = null;
|
||||
}
|
||||
if (this.legacy) {
|
||||
this.legacy.onaudioprocess = null;
|
||||
this.legacy.disconnect();
|
||||
this.legacy = null;
|
||||
}
|
||||
this.stream?.getTracks().forEach((t) => t.stop());
|
||||
this.stream = null;
|
||||
void this.ctx?.close().catch(() => undefined);
|
||||
this.ctx = null;
|
||||
this.chunks = [];
|
||||
this.totalSamples = 0;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user