feat(phase-b): Supabase navigation store, snapshot service, auth wiring

navigationStore: rewritten off Neo4j db names — Supabase whiteboard_rooms table,
  setAuthInfo(token, userId) pattern, auto-creates default room per context on first use
snapshotService: rewritten to Supabase Storage REST (/storage/v1/object/authenticated/cc.users/…),
  setAccessToken() instance method, static methods take accessToken not dbName
AuthContext/NeoUserContext: auth injected into nav store, no Neo4j db names required
singlePlayerPage: loadNodeData no longer calls Neo4j; snapshot wired via accessToken
navigation types: NeoGraphNode updated for Supabase-backed tree structure
transcriptionStore/Service: getSession() removed, accessToken via AuthContext
LLMConfigModal: auth context wiring fixes
GraphNavigator/GraphSidebar: updated nav components

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-26 01:25:15 +01:00
co-authored by Claude Sonnet 4.6
parent 3a65cf436b
commit b0c7758135
11 changed files with 1641 additions and 1892 deletions
@@ -7,6 +7,14 @@ export interface TranscriptionConfig {
useVad?: boolean;
}
export interface ServerSegment {
text: string;
start: number;
end: number;
}
type ServerSegmentsCallback = (segments: ServerSegment[], isLastLive: boolean) => void;
export class TranscriptionService {
private socket: WebSocket | null = null;
private stream: MediaStream | null = null;
@@ -14,27 +22,29 @@ export class TranscriptionService {
private mediaStreamSource: MediaStreamAudioSourceNode | null = null;
private workletNode: AudioWorkletNode | null = null;
private selectedDeviceId: string = '';
private finalizedSegmentCount: number = 0;
private onTranscriptionUpdate: ((text: string, isFinal: boolean, metadata: { start: number, end: number }) => void) | null = null;
private intentionalStop: boolean = false;
private onServerSegments: ServerSegmentsCallback | null = null;
private onDisconnect: (() => void) | null = null;
constructor(deviceId: string = '') {
this.selectedDeviceId = deviceId;
}
setTranscriptionCallback(callback: (text: string, isFinal: boolean, metadata: { start: number, end: number }) => void) {
this.onTranscriptionUpdate = callback;
setServerSegmentsCallback(callback: ServerSegmentsCallback) {
this.onServerSegments = callback;
}
setDisconnectCallback(callback: () => void) {
this.onDisconnect = callback;
}
async startTranscription(config: TranscriptionConfig = {}) {
console.log('🎙️ Starting transcription service...');
this.intentionalStop = false;
try {
logger.info('transcription-service', '🔊 Requesting microphone access...');
// Call getUserMedia directly — this triggers the browser permission prompt.
// The old code called enumerateDevices() first to find a device ID, but
// without microphone permission deviceId is always (empty string, falsy),
// causing an early return that never prompted the user for permission.
const audioConstraints: MediaTrackConstraints = this.selectedDeviceId
? { deviceId: { exact: this.selectedDeviceId } }
: { echoCancellation: true, noiseSuppression: true };
@@ -60,13 +70,13 @@ export class TranscriptionService {
clearTimeout(connectionTimeout);
logger.info('transcription-service', '✅ WebSocket connected');
// Send initial configuration — audio capture starts only after SERVER_READY.
ws.send(JSON.stringify({
uid: uuid,
language: config.language || 'en',
task: config.task || 'transcribe',
model: config.modelSize || 'base',
model: config.modelSize || 'large-v3',
use_vad: config.useVad ?? true,
max_connection_time: 7200, // server default is 600 s — set to 2 h
}));
};
@@ -76,17 +86,18 @@ export class TranscriptionService {
ws.onclose = () => {
logger.info('transcription-service', '🔌 WebSocket closed');
const wasIntentional = this.intentionalStop;
this.cleanup();
if (!wasIntentional && this.onDisconnect) {
this.onDisconnect();
}
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.uid !== uuid) {
return;
}
if (data.uid !== uuid) return;
if (data.message === 'SERVER_READY') {
// Server is ready — now safe to start streaming audio.
logger.info('transcription-service', '🟢 Server ready, starting audio capture');
this.setupAudioProcessing();
return;
@@ -94,37 +105,29 @@ export class TranscriptionService {
if (data.status === 'WAIT') {
logger.info('transcription-service', `⏳ Wait time: ${Math.round(data.message)} minutes`);
this.intentionalStop = true;
this.cleanup();
return;
}
if (data.message === 'DISCONNECT') {
logger.info('transcription-service', '🔕 Server requested disconnection');
this.intentionalStop = true;
this.cleanup();
return;
}
if (this.onTranscriptionUpdate && data.segments && data.segments.length > 0) {
const segments = data.segments;
const lastIdx = segments.length - 1;
// Only emit segments we have not finalized yet — avoids re-processing the
// full array on every message (which caused the stuck last segment bug).
for (let i = this.finalizedSegmentCount; i < lastIdx; i++) {
const seg = segments[i];
this.onTranscriptionUpdate(seg.text, true, {
start: parseFloat(seg.start),
end: parseFloat(seg.end),
});
this.finalizedSegmentCount = i + 1;
}
// Always update the live (last) segment
const lastSeg = segments[lastIdx];
this.onTranscriptionUpdate(lastSeg.text, lastSeg.completed ?? false, {
start: parseFloat(lastSeg.start),
end: parseFloat(lastSeg.end),
});
// Pass the full segment window directly to the store — the store owns
// all boundary and archival decisions, matching the WhisperLive reference
// frontend which simply re-renders the server's authoritative segment list.
if (this.onServerSegments && data.segments && data.segments.length > 0) {
const segs: ServerSegment[] = data.segments.map((s: any) => ({
text: String(s.text ?? ''),
start: parseFloat(s.start ?? 0),
end: parseFloat(s.end ?? 0),
}));
const isLastLive = !(data.segments[data.segments.length - 1]?.completed);
this.onServerSegments(segs, isLastLive);
}
};
} catch (error) {
@@ -134,26 +137,18 @@ export class TranscriptionService {
}
private async setupAudioProcessing() {
if (!this.stream || !this.socket) {
return;
}
if (!this.stream || !this.socket) return;
try {
// Request 16 kHz from the browser — it resamples natively so we send
// the correct rate to the server without any JS resampling overhead.
this.audioContext = new AudioContext({ sampleRate: 16000 });
await this.audioContext.audioWorklet.addModule('/audioWorklet.js');
this.mediaStreamSource = this.audioContext.createMediaStreamSource(this.stream);
this.workletNode = new AudioWorkletNode(this.audioContext, 'audio-processor');
// The worklet accumulates 4096 samples (256 ms at 16 kHz) before posting,
// matching the reference frontend chunk size and eliminating the tiny-frame
// flood that was overwhelming the server during silence.
this.workletNode.port.onmessage = (event) => {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(event.data); // event.data is a transferred ArrayBuffer
this.socket.send(event.data);
}
};
@@ -165,7 +160,7 @@ export class TranscriptionService {
}
stopTranscription() {
// Signal the server cleanly so it can finalise the last segment.
this.intentionalStop = true;
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send('END_OF_AUDIO');
}
@@ -173,27 +168,22 @@ export class TranscriptionService {
}
private cleanup() {
this.finalizedSegmentCount = 0;
if (this.workletNode) {
this.workletNode.disconnect();
this.workletNode = null;
}
if (this.mediaStreamSource) {
this.mediaStreamSource.disconnect();
this.mediaStreamSource = null;
}
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
if (this.socket) {
this.socket.close();
this.socket = null;