feat(dashboard): voice bridge activity panel

This commit is contained in:
L'électron rare
2026-05-04 00:32:39 +02:00
parent 8db4ee986f
commit 21a0e51ac4
6 changed files with 662 additions and 0 deletions
@@ -6,6 +6,7 @@ import { NpcPanel } from './NpcPanel.js';
import { Timeline } from './Timeline.js';
import { ControlPanel } from './ControlPanel.js';
import { HintsAdaptivePanel } from './HintsAdaptivePanel.js';
import { VoiceActivityPanel } from './VoiceActivityPanel.js';
export function ExpertDashboard() {
const {
@@ -67,6 +68,9 @@ export function ExpertDashboard() {
{/* Hints adaptive (P4) */}
<HintsAdaptivePanel />
{/* Voice bridge activity (P5) */}
<VoiceActivityPanel />
{/* Controls */}
<ControlPanel />
</div>
@@ -0,0 +1,209 @@
/**
* VoiceActivityPanel — live status of the voice-bridge (F5-TTS) on MacStudio.
*
* Surfaces three things to the GM:
* 1. Health badge (ready / warmup / down) driven by /health/ready.
* 2. Compact stats: warmup time, cache hit rate, count, size on disk.
* 3. Activity pulse: any change in (cache.hits + cache.misses) between two
* polls flashes a "voice TTS just synthesized" indicator for 1 s.
*
* The /voice/ws WebSocket is consumed by the firmware, not the dashboard, so
* this panel is the only window the GM has into voice activity in real time.
*/
import { useEffect, useRef, useState } from 'react';
import { useVoiceBridge } from '../hooks/useVoiceBridge.js';
type Status = 'ready' | 'warmup' | 'down';
interface Props {
/** Override base URL (otherwise read from VITE_VOICE_BRIDGE_URL). */
baseUrl?: string;
/** Override poll interval in ms (default 2000). */
pollMs?: number;
}
function formatHostFromUrl(url: string): string {
try {
const u = new URL(url);
return u.host;
} catch {
return url;
}
}
function deriveStatus(args: {
error: ReturnType<typeof useVoiceBridge>['error'];
ready: boolean;
f5_loaded: boolean;
}): Status {
if (args.error !== null && !args.ready) return 'down';
if (args.ready && args.f5_loaded) return 'ready';
return 'warmup';
}
const STATUS_LABEL: Record<Status, string> = {
ready: '\u{1F7E2} ready',
warmup: '\u{1F7E1} warmup',
down: '\u{1F534} down',
};
const STATUS_TITLE: Record<Status, string> = {
ready: 'F5-TTS chargé, prêt à synthétiser',
warmup: 'F5-TTS en cours de chargement (warmup)',
down: 'Voice-bridge injoignable ou erreur HTTP',
};
const STATUS_CLASS: Record<Status, string> = {
ready: 'bg-green-500/20 text-green-300',
warmup: 'bg-yellow-500/20 text-yellow-300',
down: 'bg-red-500/20 text-red-300',
};
export function VoiceActivityPanel(props: Props) {
const opts: Parameters<typeof useVoiceBridge>[0] = {};
if (props.baseUrl !== undefined) opts.baseUrl = props.baseUrl;
if (props.pollMs !== undefined) opts.pollMs = props.pollMs;
const {
ready,
f5_loaded,
warmup_ms,
cache,
latency_ms_health,
error,
loading,
refetch,
baseUrl,
pollMs,
} = useVoiceBridge(opts);
const status = deriveStatus({ error, ready, f5_loaded });
// Activity detection — when (hits + misses) increases between two polls, we
// know the bridge synthesized at least one new utterance. We pulse a badge
// for 1 s. We track the previous total in a ref so re-renders triggered by
// unrelated state (e.g. latency update) don't accidentally re-trigger.
const lastActivityTotalRef = useRef<number>(cache.hits + cache.misses);
const [pulsing, setPulsing] = useState<boolean>(false);
useEffect(() => {
const total = cache.hits + cache.misses;
if (total > lastActivityTotalRef.current) {
setPulsing(true);
const id = setTimeout(() => setPulsing(false), 1000);
lastActivityTotalRef.current = total;
return () => clearTimeout(id);
}
lastActivityTotalRef.current = total;
}, [cache.hits, cache.misses]);
const hitRatePct = Math.round(cache.hit_rate_since_boot * 100);
const warmupS = warmup_ms > 0 ? (warmup_ms / 1000).toFixed(1) : '—';
// One-line monitoring command users can copy-paste from the panel.
const monitorCmd = `watch -n 2 'curl -s ${baseUrl}/tts/cache/stats | jq'`;
return (
<section className="w-72 p-4 border-l border-white/10 overflow-y-auto">
<header className="flex items-center justify-between mb-3 gap-2">
<h2 className="text-xs font-semibold text-white/60 uppercase tracking-wide">
{'\u{1F399}\u{FE0F} Voice Bridge'}
</h2>
<div className="flex items-center gap-1.5">
<span
className={`text-[9px] px-1.5 py-0.5 rounded-full uppercase tracking-wide font-mono ${
STATUS_CLASS[status]
} ${pulsing ? 'animate-pulse ring-2 ring-cyan-300/60' : ''}`}
title={STATUS_TITLE[status]}
>
{STATUS_LABEL[status]}
</span>
<button
onClick={() => void refetch()}
className="text-[10px] px-2 py-0.5 rounded-full bg-[#2c2c2e] hover:bg-[#3a3a3c] text-white/70"
title="Recharger maintenant"
>
{loading ? '…' : '↻'}
</button>
</div>
</header>
<div className="text-[10px] text-white/40 mb-3 leading-relaxed">
<div>
{formatHostFromUrl(baseUrl)} · poll {Math.round(pollMs / 1000)}s
{latency_ms_health > 0 && <span> · RTT {latency_ms_health}ms</span>}
</div>
<div>
F5 :{' '}
<span className={f5_loaded ? 'text-green-400' : 'text-orange-400'}>
{f5_loaded ? 'chargé' : 'pas chargé'}
</span>
{warmup_ms > 0 && <span> · warmup {warmupS}s</span>}
</div>
</div>
{error !== null && (
<div className="mb-3 p-2 rounded-lg bg-red-500/10 border border-red-500/30 text-[11px] text-red-400">
Voice-bridge HS : {error.kind === 'http' ? `HTTP ${error.status}` : 'réseau'} {' '}
{error.message}
</div>
)}
<div
className={`rounded-xl border p-2.5 mb-3 transition-colors ${
pulsing
? 'border-cyan-300/60 bg-cyan-300/10'
: 'border-white/10 bg-[#2c2c2e]'
}`}
aria-live="polite"
>
<div className="text-[10px] uppercase tracking-wide text-white/50 mb-1.5">
Cache TTS
</div>
<div className="grid grid-cols-2 gap-x-2 gap-y-1 text-[11px] text-white/70">
<span>
Entrées :{' '}
<span className="text-white/95 font-mono">{cache.count}</span>
</span>
<span>
Taille :{' '}
<span className="text-white/95 font-mono">
{cache.size_mb.toFixed(1)} Mo
</span>
</span>
<span>
Hits :{' '}
<span className="text-white/95 font-mono">{cache.hits}</span>
</span>
<span>
Misses :{' '}
<span className="text-white/95 font-mono">{cache.misses}</span>
</span>
<span className="col-span-2">
Taux hit :{' '}
<span
className={
hitRatePct >= 50
? 'text-green-400 font-mono'
: 'text-orange-300 font-mono'
}
>
{hitRatePct}%
</span>{' '}
<span className="text-white/40">depuis boot</span>
</span>
</div>
{pulsing && (
<div className="mt-1.5 text-[10px] text-cyan-300 italic">
voice TTS just synthesized
</div>
)}
</div>
<div className="text-[10px] text-white/40 leading-relaxed">
<div className="uppercase tracking-wide mb-1">Monitoring</div>
<code className="block p-1.5 rounded bg-black/40 text-white/70 font-mono text-[10px] break-all whitespace-pre-wrap">
{monitorCmd}
</code>
</div>
</section>
);
}
@@ -0,0 +1,233 @@
/**
* @vitest-environment jsdom
*
* Tests for useVoiceBridge — exercises the parallel polling, the cleanup of
* the interval/AbortController on unmount, and the parsing of the two
* voice-bridge endpoints (/health/ready + /tts/cache/stats).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, useEffect } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { VoiceBridgeReady, VoiceBridgeCacheStats } from '@zacus/shared';
import { useVoiceBridge, type UseVoiceBridgeResult } from './useVoiceBridge.js';
// React 19 reads this flag to enable `act` semantics.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const READY_FIXTURE: VoiceBridgeReady = {
ready: true,
f5_loaded: true,
warmup_ms: 8421,
cache_size: 12,
};
const CACHE_FIXTURE: VoiceBridgeCacheStats = {
count: 12,
size_mb: 3.4,
hits: 50,
misses: 8,
hit_rate_since_boot: 0.86,
};
function makeFetchMock(): ReturnType<typeof vi.fn> {
return vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/health/ready')) {
return {
ok: true,
status: 200,
json: async () => READY_FIXTURE,
};
}
if (url.includes('/tts/cache/stats')) {
return {
ok: true,
status: 200,
json: async () => CACHE_FIXTURE,
};
}
throw new Error(`unexpected fetch: ${url}`);
});
}
// ---------------------------------------------------------------------------
// Tiny capture-component (mirrors useHintsEngine.test.tsx)
// ---------------------------------------------------------------------------
function HookProbe({
onResult,
options,
}: {
onResult: (r: UseVoiceBridgeResult) => void;
options: Parameters<typeof useVoiceBridge>[0];
}) {
const result = useVoiceBridge(options);
useEffect(() => {
onResult(result);
});
return null;
}
let container: HTMLDivElement | null = null;
let root: Root | null = null;
async function renderHook(
options: Parameters<typeof useVoiceBridge>[0],
): Promise<{ getResult: () => UseVoiceBridgeResult }> {
container = document.createElement('div');
document.body.appendChild(container);
let last: UseVoiceBridgeResult | null = null;
root = createRoot(container);
await act(async () => {
root!.render(<HookProbe options={options} onResult={(r) => (last = r)} />);
});
return {
getResult: () => {
if (!last) throw new Error('hook never produced a result');
return last;
},
};
}
async function unmount(): Promise<void> {
if (root) {
await act(async () => {
root!.unmount();
});
root = null;
}
if (container) {
container.remove();
container = null;
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('useVoiceBridge — polling + cleanup', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(async () => {
await unmount();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('polls /health/ready and /tts/cache/stats in parallel on each tick', async () => {
const fetchMock = makeFetchMock();
vi.stubGlobal('fetch', fetchMock);
const probe = await renderHook({
baseUrl: 'http://test.local:8200',
pollMs: 2_000,
});
// Allow the initial refetch's promises to flush.
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const initialCalls = fetchMock.mock.calls.length;
// First tick = both endpoints hit at least once.
expect(initialCalls).toBeGreaterThanOrEqual(2);
const urls = fetchMock.mock.calls.map((c) => String(c[0]));
expect(urls.some((u) => u.endsWith('/health/ready'))).toBe(true);
expect(urls.some((u) => u.endsWith('/tts/cache/stats'))).toBe(true);
// Hook reflects the parsed payloads.
const r = probe.getResult();
expect(r.ready).toBe(true);
expect(r.f5_loaded).toBe(true);
expect(r.warmup_ms).toBe(8421);
expect(r.cache.hits).toBe(50);
expect(r.cache.hit_rate_since_boot).toBeCloseTo(0.86);
// Advance one poll interval — both endpoints should be hit again.
await act(async () => {
vi.advanceTimersByTime(2_000);
await Promise.resolve();
await Promise.resolve();
});
expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(initialCalls + 2);
});
it('stops polling on unmount and aborts the in-flight request', async () => {
const fetchMock = makeFetchMock();
vi.stubGlobal('fetch', fetchMock);
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval');
await renderHook({ baseUrl: 'http://test.local:8200', pollMs: 2_000 });
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const beforeUnmount = fetchMock.mock.calls.length;
expect(beforeUnmount).toBeGreaterThanOrEqual(2);
await unmount();
expect(clearIntervalSpy).toHaveBeenCalled();
// After unmount, advancing timers must NOT trigger any new fetch.
await act(async () => {
vi.advanceTimersByTime(10_000);
await Promise.resolve();
});
expect(fetchMock.mock.calls.length).toBe(beforeUnmount);
});
it('treats /health/ready 503 (warmup) as non-error and parses body', async () => {
const warmupReady: VoiceBridgeReady = {
ready: false,
f5_loaded: false,
warmup_ms: 0,
cache_size: 0,
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/health/ready')) {
return {
ok: false,
status: 503,
json: async () => warmupReady,
};
}
return {
ok: true,
status: 200,
json: async () => CACHE_FIXTURE,
};
});
vi.stubGlobal('fetch', fetchMock);
const probe = await renderHook({
baseUrl: 'http://test.local:8200',
pollMs: 2_000,
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const r = probe.getResult();
expect(r.ready).toBe(false);
expect(r.f5_loaded).toBe(false);
expect(r.error).toBeNull(); // 503 during warmup is expected, not an error
});
});
@@ -0,0 +1,190 @@
/**
* useVoiceBridge — REST polling hook against the voice-bridge FastAPI server
* (tools/voice/bridge.py running on the MacStudio).
*
* Polls two endpoints in parallel on a fixed interval:
* - GET /health/ready → F5-TTS warmup status (ready / loaded / warmup_ms)
* - GET /tts/cache/stats → cache (count, size_mb, hits, misses, hit_rate)
*
* The dashboard cannot subscribe to the WebSocket /voice/ws because that
* channel is reserved for firmware streaming. Polling cache stats is the
* cheapest way to surface "voice TTS just synthesized" activity to the GM:
* any change in (hits + misses) between two polls is treated as new TTS work.
*
* Configuration (Vite env, all optional):
* VITE_VOICE_BRIDGE_URL base URL (default 100.116.92.12:8200)
* VITE_VOICE_BRIDGE_POLL_MS poll interval in ms (default 2000)
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
VOICE_BRIDGE_DEFAULT_BASE_URL,
VOICE_BRIDGE_DEFAULT_POLL_MS,
type VoiceBridgeReady,
type VoiceBridgeCacheStats,
} from '@zacus/shared';
export type VoiceBridgeError =
| { kind: 'network'; message: string }
| { kind: 'http'; status: number; message: string };
export interface UseVoiceBridgeOptions {
/** Override base URL (otherwise read from VITE_VOICE_BRIDGE_URL). */
baseUrl?: string;
/** Override poll interval in ms (otherwise read from VITE_VOICE_BRIDGE_POLL_MS). */
pollMs?: number;
/** Disable polling (manual mode — call refetch()). */
paused?: boolean;
}
export interface UseVoiceBridgeResult {
ready: boolean;
f5_loaded: boolean;
warmup_ms: number;
cache: VoiceBridgeCacheStats;
/** Round-trip latency for the last /health/ready call, in ms. */
latency_ms_health: number;
error: VoiceBridgeError | null;
loading: boolean;
refetch: () => Promise<void>;
baseUrl: string;
pollMs: number;
}
interface ImportMetaEnvLike {
VITE_VOICE_BRIDGE_URL?: string;
VITE_VOICE_BRIDGE_POLL_MS?: string;
}
function readEnv(): ImportMetaEnvLike {
// import.meta.env is replaced at build time by Vite. In tests / Node it may
// not exist — fall back to an empty object.
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const env = (import.meta as any)?.env;
return (env as ImportMetaEnvLike) ?? {};
} catch {
return {};
}
}
const EMPTY_CACHE: VoiceBridgeCacheStats = {
count: 0,
size_mb: 0,
hits: 0,
misses: 0,
hit_rate_since_boot: 0,
};
export function useVoiceBridge(opts: UseVoiceBridgeOptions = {}): UseVoiceBridgeResult {
const env = useMemo(() => readEnv(), []);
const baseUrl = (opts.baseUrl ?? env.VITE_VOICE_BRIDGE_URL ?? VOICE_BRIDGE_DEFAULT_BASE_URL).replace(
/\/+$/,
'',
);
const pollMs =
opts.pollMs ?? Number(env.VITE_VOICE_BRIDGE_POLL_MS ?? VOICE_BRIDGE_DEFAULT_POLL_MS);
const [ready, setReady] = useState<boolean>(false);
const [f5Loaded, setF5Loaded] = useState<boolean>(false);
const [warmupMs, setWarmupMs] = useState<number>(0);
const [cache, setCache] = useState<VoiceBridgeCacheStats>(EMPTY_CACHE);
const [latencyMsHealth, setLatencyMsHealth] = useState<number>(0);
const [error, setError] = useState<VoiceBridgeError | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const abortRef = useRef<AbortController | null>(null);
const refetch = useCallback(async () => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
const t0 = Date.now();
try {
// Fire both probes in parallel — they target the same FastAPI process,
// so latency is dominated by network RTT. We treat /health/ready as the
// primary error source (503 is expected during F5 warmup and is NOT an
// error on its own — we still parse the body).
const [readyResp, cacheResp] = await Promise.all([
fetch(`${baseUrl}/health/ready`, { signal: ctrl.signal }),
fetch(`${baseUrl}/tts/cache/stats`, { signal: ctrl.signal }),
]);
setLatencyMsHealth(Date.now() - t0);
// /health/ready returns 200 when ready, 503 during warmup. Both carry
// the same JSON body — we always parse it.
if (readyResp.status !== 200 && readyResp.status !== 503) {
setError({
kind: 'http',
status: readyResp.status,
message: `GET /health/ready → ${readyResp.status}`,
});
setReady(false);
} else {
const data = (await readyResp.json()) as VoiceBridgeReady;
setReady(Boolean(data.ready));
setF5Loaded(Boolean(data.f5_loaded));
setWarmupMs(typeof data.warmup_ms === 'number' ? data.warmup_ms : 0);
if (readyResp.status === 200 || readyResp.status === 503) setError(null);
}
if (!cacheResp.ok) {
setError({
kind: 'http',
status: cacheResp.status,
message: `GET /tts/cache/stats → ${cacheResp.status}`,
});
} else {
const stats = (await cacheResp.json()) as VoiceBridgeCacheStats;
setCache({
count: typeof stats.count === 'number' ? stats.count : 0,
size_mb: typeof stats.size_mb === 'number' ? stats.size_mb : 0,
hits: typeof stats.hits === 'number' ? stats.hits : 0,
misses: typeof stats.misses === 'number' ? stats.misses : 0,
hit_rate_since_boot:
typeof stats.hit_rate_since_boot === 'number' ? stats.hit_rate_since_boot : 0,
});
}
} catch (err) {
// AbortError is expected during teardown — swallow.
if (err instanceof DOMException && err.name === 'AbortError') return;
const message = err instanceof Error ? err.message : String(err);
setError({ kind: 'network', message });
setReady(false);
} finally {
setLoading(false);
}
}, [baseUrl]);
// --------------------------------------------------------------------------
// Polling effect — fires immediately, then every `pollMs`. Re-arms whenever
// baseUrl, pollMs, or paused changes.
// --------------------------------------------------------------------------
useEffect(() => {
if (opts.paused) return;
void refetch();
if (pollMs <= 0) return;
const id = setInterval(() => {
void refetch();
}, pollMs);
return () => {
clearInterval(id);
abortRef.current?.abort();
};
}, [opts.paused, pollMs, refetch]);
return {
ready,
f5_loaded: f5Loaded,
warmup_ms: warmupMs,
cache,
latency_ms_health: latencyMsHealth,
error,
loading,
refetch,
baseUrl,
pollMs,
};
}
@@ -28,3 +28,8 @@ export const BOX3_WS_RECONNECT_MS = 3000;
export const HINTS_DEFAULT_BASE_URL = 'http://localhost:8311';
export const HINTS_DEFAULT_POLL_MS = 5000;
export const HINTS_GROUP_PROFILES = ['TECH', 'NON_TECH', 'MIXED', 'BOTH'] as const;
// Voice-bridge REST defaults — overridden via VITE_VOICE_BRIDGE_URL / VITE_VOICE_BRIDGE_POLL_MS
// 100.116.92.12:8200 = MacStudio Tailscale IP, where the F5-TTS bridge runs.
export const VOICE_BRIDGE_DEFAULT_BASE_URL = 'http://100.116.92.12:8200';
export const VOICE_BRIDGE_DEFAULT_POLL_MS = 2000;
+21
View File
@@ -248,6 +248,27 @@ export interface HintAskResponse {
group_profile_used: HintsGroupProfile | null;
}
// ============================================================
// VOICE BRIDGE TYPES (mirror tools/voice/bridge.py FastAPI surface)
// ============================================================
/** GET /health/ready envelope. F5-TTS warmup status. */
export interface VoiceBridgeReady {
ready: boolean;
f5_loaded: boolean;
warmup_ms: number;
cache_size: number;
}
/** GET /tts/cache/stats envelope. */
export interface VoiceBridgeCacheStats {
count: number;
size_mb: number;
hits: number;
misses: number;
hit_rate_since_boot: number;
}
export interface ScenarioYaml {
id: string;
version: string;