keet-streaming / src /components /DebugPanel.tsx
ysdede's picture
feat(space): migrate Hugging Face Space to keet SolidJS app
b8cc2bf
import { Component, createMemo, For, Show, createSignal, onCleanup, createEffect } from 'solid-js';
import { appStore, type TranscriptionMode } from '../stores/appStore';
import type { AudioEngine } from '../lib/audio/types';
import type { MelWorkerClient } from '../lib/audio/MelWorkerClient';
import { LayeredBufferVisualizer } from './LayeredBufferVisualizer';
interface DebugPanelProps {
audioEngine?: AudioEngine;
melClient?: MelWorkerClient;
}
const MODES: { id: TranscriptionMode; label: string; short: string }[] = [
{ id: 'v4-utterance', label: 'Utterance (v4)', short: 'v4' },
{ id: 'v3-streaming', label: 'Streaming (v3)', short: 'v3' },
{ id: 'v2-utterance', label: 'Legacy (v2)', short: 'v2' },
];
export const DebugPanel: Component<DebugPanelProps> = (props) => {
const isRecording = () => appStore.recordingState() === 'recording';
const isV4 = () => appStore.transcriptionMode() === 'v4-utterance';
const isV3 = () => appStore.transcriptionMode() === 'v3-streaming';
const [height, setHeight] = createSignal(260);
const [isResizing, setIsResizing] = createSignal(false);
let startY = 0;
let startHeight = 0;
let scrollContainer: HTMLDivElement | undefined;
// Auto-scroll to bottom of finalized sentences
createEffect(() => {
appStore.matureText(); // Track dependency
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
});
const handleMouseDown = (e: MouseEvent) => {
setIsResizing(true);
startY = e.clientY;
startHeight = height();
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
};
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing()) return;
const delta = startY - e.clientY;
const newHeight = Math.min(Math.max(startHeight + delta, 150), 600);
setHeight(newHeight);
};
const handleMouseUp = () => {
setIsResizing(false);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
onCleanup(() => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
});
const rtfColor = createMemo(() => {
const rtfx = appStore.rtfxAverage();
if (rtfx === 0) return 'text-[var(--color-earthy-soft-brown)]';
if (rtfx >= 2) return 'text-[var(--color-earthy-muted-green)] font-bold';
if (rtfx >= 1) return 'text-[var(--color-earthy-coral)] font-bold';
return 'text-[var(--color-earthy-coral)] font-bold';
});
return (
<div
class="bg-[var(--color-earthy-bg)] border-t border-[var(--color-earthy-sage)] text-[10px] font-mono text-[var(--color-earthy-dark-brown)] flex overflow-hidden shrink-0 transition-colors duration-300 selection:bg-[var(--color-earthy-coral)]/20 selection:text-[var(--color-earthy-coral)] z-20 relative"
style={{ height: `${height()}px` }}
>
{/* Resize Handle */}
<div
class="absolute top-0 left-0 w-full h-1 cursor-ns-resize z-50 hover:bg-[var(--color-earthy-muted-green)]/50 transition-colors bg-transparent"
onMouseDown={handleMouseDown}
/>
{/* ---- Column 1: System & Signal (merged indicators) ---- */}
<div class="w-60 flex flex-col p-3 gap-2.5 border-r border-[var(--color-earthy-sage)] bg-[var(--color-earthy-sage)]/10 overflow-y-auto">
<div class="flex items-center justify-between pb-2 border-b border-[var(--color-earthy-sage)]">
<span class="font-bold tracking-wider uppercase text-[var(--color-earthy-soft-brown)]">System & Signal</span>
<div class="flex items-center gap-2">
<span class="font-bold text-[var(--color-earthy-soft-brown)] uppercase text-[9px]">{appStore.backend()}</span>
<div class={`w-2 h-2 rounded-full border border-[var(--color-earthy-bg)] shadow-sm transition-all duration-300 ${isRecording() ? 'bg-[var(--color-earthy-coral)] animate-pulse' : 'bg-[var(--color-earthy-sage)]'}`} />
<span class={`font-bold text-white bg-[var(--color-earthy-coral)] px-1.5 py-px rounded text-[9px] transition-opacity duration-100 ${appStore.isSpeechDetected() ? 'opacity-100 animate-pulse' : 'opacity-0'}`}>VAD</span>
</div>
</div>
<div class="space-y-1.5">
<span class="font-bold text-[9px] text-[var(--color-earthy-soft-brown)] uppercase tracking-wider">Mode</span>
<div class="flex gap-1">
<For each={MODES}>
{(mode) => (
<button
class={`flex-1 px-1 py-1 rounded text-[9px] font-bold uppercase tracking-wide border transition-all ${appStore.transcriptionMode() === mode.id
? 'bg-[var(--color-earthy-muted-green)] text-white border-[var(--color-earthy-muted-green)] shadow-sm'
: 'bg-[var(--color-earthy-bg)] text-[var(--color-earthy-soft-brown)] border-[var(--color-earthy-sage)] hover:border-[var(--color-earthy-soft-brown)] hover:bg-[var(--color-earthy-sage)]/20'
} ${isRecording() ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
onClick={() => {
if (!isRecording()) {
appStore.setTranscriptionMode(mode.id);
}
}}
disabled={isRecording()}
title={isRecording() ? 'Stop recording to change mode' : mode.label}
>
{mode.short}
</button>
)}
</For>
</div>
</div>
<div class="grid grid-cols-2 gap-1.5">
<div class="bg-[var(--color-earthy-bg)] border border-[var(--color-earthy-sage)] rounded p-1.5 flex flex-col items-center justify-center">
<span class="font-bold text-[var(--color-earthy-soft-brown)] uppercase tracking-tight text-[8px] mb-0.5">RTFx</span>
<span class={`text-xs ${rtfColor()}`}>
{appStore.rtfxAverage() > 0 ? Math.round(appStore.rtfxAverage()) : '–'}
</span>
</div>
<div class="bg-[var(--color-earthy-bg)] border border-[var(--color-earthy-sage)] rounded p-1.5 flex flex-col items-center justify-center">
<span class="font-bold text-[var(--color-earthy-soft-brown)] uppercase tracking-tight text-[8px] mb-0.5">Latency</span>
<span class="text-xs font-bold text-[var(--color-earthy-dark-brown)]">{Math.round(appStore.inferenceLatencyAverage())}ms</span>
</div>
</div>
<div class="space-y-1">
<div class="flex justify-between font-bold text-[var(--color-earthy-soft-brown)] uppercase px-0.5 text-[9px]">
<span>Buffer</span>
<span>{(appStore.bufferMetrics().fillRatio * 100).toFixed(0)}%</span>
</div>
<div class="h-1.5 w-full bg-[var(--color-earthy-sage)] rounded-full overflow-hidden">
<div
class="h-full bg-[var(--color-earthy-muted-green)] transition-all duration-300 ease-out rounded-full"
style={{ width: `${(appStore.bufferMetrics().fillRatio * 100).toFixed(0)}%` }}
/>
</div>
</div>
<Show when={isV4()}>
<div class="space-y-1 pt-1 border-t border-[var(--color-earthy-sage)]">
<span class="font-bold text-[8px] text-[var(--color-earthy-soft-brown)] uppercase tracking-wider">Merger</span>
<div class="grid grid-cols-3 gap-1">
<div class="bg-[var(--color-earthy-bg)] border border-[var(--color-earthy-sage)] rounded px-1 py-0.5 text-center">
<div class="text-[7px] font-bold text-[var(--color-earthy-soft-brown)] uppercase">Sent</div>
<div class="text-[10px] font-bold text-[var(--color-earthy-dark-brown)]">{appStore.v4MergerStats().sentencesFinalized}</div>
</div>
<div class="bg-[var(--color-earthy-bg)] border border-[var(--color-earthy-sage)] rounded px-1 py-0.5 text-center">
<div class="text-[7px] font-bold text-[var(--color-earthy-soft-brown)] uppercase">Cursor</div>
<div class="text-[10px] font-bold text-[var(--color-earthy-dark-brown)]">{appStore.matureCursorTime().toFixed(1)}s</div>
</div>
<div class="bg-[var(--color-earthy-bg)] border border-[var(--color-earthy-sage)] rounded px-1 py-0.5 text-center">
<div class="text-[7px] font-bold text-[var(--color-earthy-soft-brown)] uppercase">Uttr</div>
<div class="text-[10px] font-bold text-[var(--color-earthy-dark-brown)]">{appStore.v4MergerStats().utterancesProcessed}</div>
</div>
</div>
</div>
</Show>
<div class="space-y-1">
<div class="flex justify-between font-bold text-[var(--color-earthy-soft-brown)] uppercase text-[9px]">
<span>RMS Energy</span>
<span class={appStore.audioLevel() > appStore.energyThreshold() ? 'text-[var(--color-earthy-muted-green)]' : 'text-[var(--color-earthy-soft-brown)]'}>
{(appStore.audioLevel() * 100).toFixed(1)}%
</span>
</div>
<div class="h-2 w-full bg-[var(--color-earthy-sage)] rounded overflow-hidden relative">
<div class="absolute top-0 bottom-0 w-px bg-[var(--color-earthy-coral)] z-10" style={{ left: `${appStore.energyThreshold() * 100}%` }} title="Energy threshold"></div>
<div
class={`h-full transition-all duration-75 ${appStore.isSpeechDetected() ? 'bg-[var(--color-earthy-coral)]' : 'bg-[var(--color-earthy-muted-green)]'}`}
style={{ width: `${Math.min(100, appStore.audioLevel() * 100)}%` }}
/>
</div>
</div>
<Show when={isV4()}>
<div class={`space-y-1 transition-opacity duration-300 ${appStore.vadState().sileroProbability > 0 ? 'opacity-100' : 'opacity-40'}`}>
<div class="flex justify-between font-bold text-[var(--color-earthy-soft-brown)] uppercase text-[9px]">
<span>VAD Prob</span>
<span class={appStore.vadState().sileroProbability > appStore.sileroThreshold() ? 'text-[var(--color-earthy-coral)] font-bold' : 'text-[var(--color-earthy-soft-brown)]'}>
{(appStore.vadState().sileroProbability * 100).toFixed(0)}%
</span>
</div>
<div class="h-2 w-full bg-[var(--color-earthy-sage)] rounded overflow-hidden relative">
<div class="absolute top-0 bottom-0 w-px bg-[var(--color-earthy-coral)] z-10" style={{ left: `${appStore.sileroThreshold() * 100}%` }} title="VAD threshold"></div>
<div
class={`h-full transition-all duration-75 ${appStore.vadState().sileroProbability > appStore.sileroThreshold() ? 'bg-[var(--color-earthy-coral)]' : 'bg-[var(--color-earthy-soft-brown)]'}`}
style={{ width: `${Math.min(100, appStore.vadState().sileroProbability * 100)}%` }}
/>
</div>
</div>
<div class={`flex justify-between items-center bg-[var(--color-earthy-bg)] p-1.5 rounded border border-[var(--color-earthy-sage)] transition-opacity duration-300 ${appStore.vadState().snr !== 0 ? 'opacity-100' : 'opacity-40'}`}>
<span class="font-bold text-[9px] text-[var(--color-earthy-soft-brown)] uppercase">SNR</span>
<span class={`font-bold text-[10px] ${appStore.vadState().snr > 3 ? 'text-[var(--color-earthy-muted-green)]' : 'text-[var(--color-earthy-soft-brown)]'}`}>
{appStore.vadState().snr.toFixed(1)} dB
</span>
</div>
</Show>
<div class="grid grid-cols-2 gap-1.5 pt-1 border-t border-[var(--color-earthy-sage)]">
<Show when={isV3()}>
<div class="bg-[var(--color-earthy-bg)] border border-[var(--color-earthy-sage)] rounded p-1.5 text-center">
<div class="text-[7px] font-bold text-[var(--color-earthy-soft-brown)] uppercase mb-px">Overlap</div>
<div class="text-[10px] font-bold text-[var(--color-earthy-dark-brown)]">{appStore.streamingOverlap().toFixed(1)}s</div>
</div>
<div class="bg-[var(--color-earthy-bg)] border border-[var(--color-earthy-sage)] rounded p-1.5 text-center">
<div class="text-[7px] font-bold text-[var(--color-earthy-soft-brown)] uppercase mb-px">Chunks</div>
<div class="text-[10px] font-bold text-[var(--color-earthy-dark-brown)]">{appStore.mergeInfo().chunkCount}</div>
</div>
</Show>
<Show when={isV4()}>
<div class="bg-[var(--color-earthy-bg)] border border-[var(--color-earthy-sage)] rounded p-1.5 text-center">
<div class="text-[7px] font-bold text-[var(--color-earthy-soft-brown)] uppercase mb-px">State</div>
<div class={`text-[10px] font-bold whitespace-nowrap w-24 overflow-hidden text-ellipsis mx-auto ${appStore.vadState().isSpeech ? 'text-[var(--color-earthy-coral)]' : 'text-[var(--color-earthy-soft-brown)]'}`}>
{appStore.vadState().hybridState}
</div>
</div>
<div class="bg-[var(--color-earthy-bg)] border border-[var(--color-earthy-sage)] rounded p-1.5 text-center">
<div class="text-[7px] font-bold text-[var(--color-earthy-soft-brown)] uppercase mb-px">Windows</div>
<div class="text-[10px] font-bold text-[var(--color-earthy-dark-brown)]">{appStore.v4MergerStats().utterancesProcessed}</div>
</div>
</Show>
</div>
</div>
{/* ---- Column 2: Live Context (mode-dependent) ---- */}
<div class="flex-1 flex flex-col min-w-0 bg-[var(--color-earthy-bg)]">
<div class="px-3 py-2 border-b border-[var(--color-earthy-sage)] bg-[var(--color-earthy-sage)]/10 flex items-center justify-between">
<span class="font-bold tracking-wider uppercase text-[var(--color-earthy-soft-brown)]">
{isV4() ? 'Transcript State' : isV3() ? 'Stream Sync' : 'Segments'}
</span>
{/* v3: LCS indicators */}
<Show when={isV3()}>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5 px-2 py-0.5 bg-[var(--color-earthy-bg)] rounded border border-[var(--color-earthy-sage)]">
<div class={`w-1.5 h-1.5 rounded-full transition-colors duration-300 ${appStore.mergeInfo().anchorValid ? 'bg-[var(--color-earthy-muted-green)]' : 'bg-[var(--color-earthy-coral)]'}`} />
<span class="font-bold uppercase text-[var(--color-earthy-soft-brown)] tracking-wide">Lock</span>
</div>
<div class="flex items-center gap-1.5 px-2 py-0.5 bg-[var(--color-earthy-bg)] rounded border border-[var(--color-earthy-sage)]">
<span class="material-symbols-outlined text-[14px] text-[var(--color-earthy-soft-brown)]">join_inner</span>
<span class="font-bold uppercase text-[var(--color-earthy-dark-brown)]">Match: <span class="text-[var(--color-earthy-muted-green)]">{appStore.mergeInfo().lcsLength}</span></span>
</div>
</div>
</Show>
{/* v4: VAD state indicator */}
<Show when={isV4()}>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5 px-2 py-0.5 bg-[var(--color-earthy-bg)] rounded border border-[var(--color-earthy-sage)]">
<div class={`w-1.5 h-1.5 rounded-full transition-colors duration-300 ${appStore.vadState().isSpeech ? 'bg-[var(--color-earthy-coral)] animate-pulse' : 'bg-[var(--color-earthy-sage)]'}`} />
<div class="w-24 overflow-hidden text-ellipsis whitespace-nowrap">
<span class="font-bold uppercase text-[var(--color-earthy-soft-brown)] tracking-wide">{appStore.vadState().hybridState}</span>
</div>
</div>
<div class={`flex items-center gap-1.5 px-2 py-0.5 bg-[var(--color-earthy-bg)] rounded border border-[var(--color-earthy-sage)] transition-opacity duration-300 ${appStore.vadState().sileroProbability > 0 ? 'opacity-100' : 'opacity-0'}`}>
<span class="font-bold uppercase text-[var(--color-earthy-soft-brown)] text-[9px]">VAD</span>
<span class={`font-bold ${appStore.vadState().sileroProbability > 0.5 ? 'text-[var(--color-earthy-coral)]' : 'text-[var(--color-earthy-soft-brown)]'}`}>
{(appStore.vadState().sileroProbability * 100).toFixed(0)}%
</span>
</div>
</div>
</Show>
</div>
<div class="flex-1 overflow-y-auto p-3 space-y-4 custom-scrollbar">
{/* v4: Mature + Immature text display */}
<Show when={isV4()}>
<div class="space-y-3">
{/* Mature (finalized) sentences */}
<div class="space-y-1.5">
<h4 class="font-bold text-[var(--color-earthy-soft-brown)] uppercase tracking-widest flex items-center gap-2 text-[9px]">
<span class="w-1.5 h-1.5 bg-[var(--color-earthy-muted-green)] rounded-full"></span>
Finalized Sentences
</h4>
<div
ref={scrollContainer}
class="p-2 border border-[var(--color-earthy-sage)] bg-[var(--color-earthy-muted-green)]/10 rounded h-32 overflow-y-auto resize-y"
>
<Show when={appStore.matureText()} fallback={
<span class="text-[var(--color-earthy-soft-brown)] italic text-[10px] opacity-50">No finalized sentences yet...</span>
}>
<span class="text-[11px] text-[var(--color-earthy-dark-brown)] leading-relaxed">{appStore.matureText()}</span>
</Show>
</div>
</div>
{/* Immature (active) sentence */}
<div class="space-y-1.5">
<h4 class="font-bold text-[var(--color-earthy-soft-brown)] uppercase tracking-widest flex items-center gap-2 text-[9px]">
<span class="w-1.5 h-1.5 bg-[var(--color-earthy-coral)] rounded-full animate-pulse"></span>
Active Sentence
</h4>
<div class="p-2 border border-[var(--color-earthy-coral)]/30 bg-[var(--color-earthy-coral)]/10 rounded min-h-[36px]">
<Show when={appStore.immatureText()} fallback={
<span class="text-[var(--color-earthy-soft-brown)] italic text-[10px] opacity-50">Waiting for speech...</span>
}>
<span class="text-[11px] text-[var(--color-earthy-coral)] italic leading-relaxed">{appStore.immatureText()}</span>
<span class="inline-block w-0.5 h-3 bg-[var(--color-earthy-coral)] animate-pulse ml-0.5 align-middle"></span>
</Show>
</div>
</div>
{/* Pending sentence info */}
<Show when={appStore.v4MergerStats().sentencesFinalized > 0}>
<div class="text-[9px] text-[var(--color-earthy-soft-brown)] flex items-center gap-3 pt-1">
<span>{appStore.v4MergerStats().sentencesFinalized} sentences finalized</span>
<span class="text-[var(--color-earthy-sage)]">|</span>
<span>Cursor at {appStore.matureCursorTime().toFixed(2)}s</span>
<span class="text-[var(--color-earthy-sage)]">|</span>
<span>{appStore.v4MergerStats().utterancesProcessed} windows processed</span>
</div>
</Show>
</div>
</Show>
{/* v3: Transition cache + anchors */}
<Show when={isV3()}>
<div class="space-y-2">
<h4 class="font-bold text-[var(--color-earthy-soft-brown)] uppercase tracking-widest flex items-center gap-2 text-[9px]">
<span class="w-1 h-1 bg-[var(--color-earthy-sage)] rounded-full"></span>
Transition Cache
</h4>
<div class="p-2 border border-[var(--color-earthy-sage)] bg-[var(--color-earthy-sage)]/10 rounded min-h-[48px] flex flex-wrap gap-1.5 content-start">
<For each={appStore.debugTokens().slice(-24)}>
{(token) => (
<div
class="px-1.5 py-0.5 rounded text-[10px] font-medium border transition-colors"
style={{
"background-color": token.confidence > 0.8 ? '#F9F7F2' : 'rgba(249,247,242,0.6)',
"border-color": `rgba(107, 112, 92, ${Math.max(0.2, token.confidence * 0.4)})`,
"color": token.confidence > 0.8 ? '#3D405B' : '#A5A58D',
"opacity": Math.max(0.5, token.confidence)
}}
title={`Confidence: ${(token.confidence * 100).toFixed(0)}%`}
>
{token.text}
</div>
)}
</For>
<Show when={appStore.pendingText()}>
<span class="px-1.5 py-0.5 text-[var(--color-earthy-coral)] font-medium italic border border-dashed border-[var(--color-earthy-coral)]/30 rounded bg-[var(--color-earthy-coral)]/10">
{appStore.pendingText()}...
</span>
</Show>
<Show when={!appStore.debugTokens().length && !appStore.pendingText()}>
<span class="text-[var(--color-earthy-soft-brown)] italic text-[10px] w-full text-center py-2 op-50">Waiting for speech input...</span>
</Show>
</div>
</div>
<div class="space-y-2">
<h4 class="font-bold text-[var(--color-earthy-soft-brown)] uppercase tracking-widest flex items-center gap-2 text-[9px]">
<span class="w-1 h-1 bg-[var(--color-earthy-muted-green)] rounded-full"></span>
Stable Anchors
</h4>
<div class="flex flex-wrap gap-1">
<For each={appStore.mergeInfo().anchorTokens || []}>
{(token) => (
<span class="px-1.5 py-0.5 bg-[var(--color-earthy-muted-green)]/20 text-[var(--color-earthy-muted-green)] border border-[var(--color-earthy-sage)] rounded font-medium">
{token}
</span>
)}
</For>
<Show when={!appStore.mergeInfo().anchorTokens?.length}>
<span class="text-[var(--color-earthy-soft-brown)] text-[10px] italic px-1 opacity-50">No stable anchors locked yet.</span>
</Show>
</div>
</div>
</Show>
{/* v2: basic info */}
<Show when={!isV3() && !isV4()}>
<div class="text-[var(--color-earthy-soft-brown)] italic text-center py-4">
Legacy per-utterance mode. Segments are transcribed individually.
</div>
</Show>
{/* New Layered Buffer Visualizer */}
<div class="pt-2 border-t border-[var(--color-earthy-sage)]">
<LayeredBufferVisualizer
audioEngine={props.audioEngine}
melClient={props.melClient}
height={120} // Compact height
windowDuration={8.0}
/>
</div>
</div>
</div>
</div>
);
};