Compare commits

..

3 Commits

Author SHA1 Message Date
7b546c933e feat(phase-a): remove Neo4j from startup chain, clean auth flow
- Remove NeoUserProvider + NeoInstituteProvider from App.tsx startup chain
- Strip user_db_name/school_db_name from CCUser; add school_id (Phase B wires it to Supabase)
- Remove DatabaseNameService from AuthContext and UserContext
- Remove provisionUser() call from login path; API endpoint preserved for Phase B decision
- Simplify UserContext.resolveProfile: fast-path JWT metadata then background Supabase fetch
- Replace user.user_db_name reads in singlePlayerPage + snapshotService with null-safe guards
- Add useDeviceContext hook (desktop/tablet/phone/iwb, persists to localStorage)

App now loads to dashboard without any Neo4j dependency at startup.
Canvas opens to blank TLDraw state; Phase B rebuilds navigation on Supabase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 13:06:39 +00:00
4139eb8fd3 fix: audio pipeline — 16 kHz AudioContext, 4096-sample buffering, SERVER_READY handshake
Root causes of disconnection and slow transcription:
- AudioWorklet was firing every 128 native samples (~48 kHz), sending
  ~375 tiny WebSocket messages/sec. Server flooded with tiny frames
  during silence → keepalive ping timed out → connection dropped.
- JS resampling 48 kHz → 16 kHz added CPU overhead on every chunk.
- Audio started on ws.onopen before server sent SERVER_READY, so early
  frames were dropped.

Fixes:
- audioWorklet.js: accumulate 4096 samples before posting (256 ms/chunk
  at 16 kHz, ~4 messages/sec), transfer ArrayBuffer zero-copy.
- transcriptionService: AudioContext({ sampleRate: 16000 }) — browser
  handles native resampling, no JS resampling needed. Remove
  resampleTo16kHZ entirely.
- Wait for SERVER_READY message before calling setupAudioProcessing().
- Send 'END_OF_AUDIO' string on stop so server can finalise last segment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 07:26:20 +00:00
308889937c fix: transcription segment dedup, store finalisation, and panel colours
- transcriptionService: track finalizedSegmentCount so only newly-final
  segments are emitted per WS message (was re-processing full array each
  time, causing the live segment to freeze in the completed list)
- transcriptionStore: saveSegment isFinal branch now appends the passed
  text directly instead of currentSegment (currentSegment was stale
  relative to the incoming final)
- CCTranscriptionPanel: record button colour changed from var(--color-text)
  to explicit #2563eb so it is visible in dark mode; completed segment
  backgrounds changed from hardcoded #fff to var(--color-muted) so text
  is readable in both themes; keyword Add button gets same blue fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 07:08:53 +00:00
12 changed files with 135 additions and 222 deletions

View File

@ -1,12 +1,26 @@
class AudioProcessor extends AudioWorkletProcessor { class AudioProcessor extends AudioWorkletProcessor {
constructor() {
super();
this._buffer = new Float32Array(4096);
this._bufferIndex = 0;
}
process(inputs) { process(inputs) {
const input = inputs[0]; const input = inputs[0];
if (input.length > 0) { if (input.length > 0) {
const audioData = input[0]; const samples = input[0];
this.port.postMessage(audioData); for (let i = 0; i < samples.length; i++) {
this._buffer[this._bufferIndex++] = samples[i];
if (this._bufferIndex >= 4096) {
// Transfer ownership (zero-copy) to main thread
this.port.postMessage(this._buffer.buffer, [this._buffer.buffer]);
this._buffer = new Float32Array(4096);
this._bufferIndex = 0;
}
}
} }
return true; return true;
} }
} }
registerProcessor('audio-processor', AudioProcessor); registerProcessor('audio-processor', AudioProcessor);

View File

@ -4,8 +4,6 @@ import { theme } from './services/themeService';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider } from './contexts/AuthContext';
import { TLDrawProvider } from './contexts/TLDrawContext'; import { TLDrawProvider } from './contexts/TLDrawContext';
import { UserProvider } from './contexts/UserContext'; import { UserProvider } from './contexts/UserContext';
import { NeoUserProvider } from './contexts/NeoUserContext';
import { NeoInstituteProvider } from './contexts/NeoInstituteContext';
import AppRoutes from './AppRoutes'; import AppRoutes from './AppRoutes';
import React from 'react'; import React from 'react';

View File

@ -4,7 +4,6 @@ import { Session, User } from '@supabase/supabase-js';
import { CCUser, CCUserMetadata, authService } from '../services/auth/authService'; import { CCUser, CCUserMetadata, authService } from '../services/auth/authService';
import { logger } from '../debugConfig'; import { logger } from '../debugConfig';
import { supabase } from '../supabaseClient'; import { supabase } from '../supabaseClient';
import { DatabaseNameService } from '../services/graph/databaseNameService';
import { storageService, StorageKeys } from '../services/auth/localStorageService'; import { storageService, StorageKeys } from '../services/auth/localStorageService';
export interface AuthContextType { export interface AuthContextType {
@ -52,20 +51,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const baseDisplayName = metadata.display_name || metadata.name || metadata.preferred_username || baseUsername; const baseDisplayName = metadata.display_name || metadata.name || metadata.preferred_username || baseUsername;
const userType = (metadata.user_type || 'email_teacher').trim(); const userType = (metadata.user_type || 'email_teacher').trim();
const storedUserDb = DatabaseNameService.getStoredUserDatabase();
const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
const userDbName = storedUserDb || DatabaseNameService.getUserPrivateDB(userType || 'standard', supabaseUser.id);
const schoolDbName = storedSchoolDb || '';
const resolvedUser: CCUser = { const resolvedUser: CCUser = {
id: supabaseUser.id, id: supabaseUser.id,
email: supabaseUser.email, email: supabaseUser.email,
user_type: userType, user_type: userType,
username: baseUsername, username: baseUsername,
display_name: baseDisplayName, display_name: baseDisplayName,
user_db_name: userDbName, school_id: null,
school_db_name: schoolDbName,
created_at: supabaseUser.created_at, created_at: supabaseUser.created_at,
updated_at: supabaseUser.updated_at updated_at: supabaseUser.updated_at
}; };

View File

@ -4,8 +4,6 @@ import { supabase } from '../supabaseClient';
import { logger } from '../debugConfig'; import { logger } from '../debugConfig';
import { CCUser, CCUserMetadata } from '../services/auth/authService'; import { CCUser, CCUserMetadata } from '../services/auth/authService';
import { UserPreferences } from '../services/auth/profileService'; import { UserPreferences } from '../services/auth/profileService';
import { DatabaseNameService } from '../services/graph/databaseNameService';
import { provisionUser } from '../services/provisioningService';
import { storageService, StorageKeys } from '../services/auth/localStorageService'; import { storageService, StorageKeys } from '../services/auth/localStorageService';
export interface UserContextType { export interface UserContextType {
@ -112,33 +110,23 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
let profileRow: Record<string, unknown> | null = null; let profileRow: Record<string, unknown> | null = null;
// Fast-path: build profile from auth metadata + localStorage immediately. // Fast-path: build profile from auth metadata immediately (no spinner on refresh).
// This clears the spinner before any network call so the page renders on refresh
// without waiting for the Supabase profiles query (~200ms).
const fastMetadata = userInfo.user_metadata as CCUserMetadata; const fastMetadata = userInfo.user_metadata as CCUserMetadata;
const fastStoredUserDb = DatabaseNameService.getStoredUserDatabase();
const fastStoredSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
const fastUserDb = fastStoredUserDb || DatabaseNameService.getUserPrivateDB(fastMetadata?.user_type || '', userInfo.id);
const fastProfile: CCUser = { const fastProfile: CCUser = {
id: userInfo.id, id: userInfo.id,
email: userInfo.email, email: userInfo.email,
user_type: fastMetadata?.user_type || '', user_type: fastMetadata?.user_type || '',
username: fastMetadata?.username || userInfo.email?.split('@')[0] || 'user', username: fastMetadata?.username || userInfo.email?.split('@')[0] || 'user',
display_name: String(fastMetadata?.display_name || ''), display_name: String(fastMetadata?.display_name || ''),
user_db_name: String(fastUserDb || ''), school_id: null,
school_db_name: String(fastStoredSchoolDb || ''),
created_at: userInfo.created_at, created_at: userInfo.created_at,
updated_at: userInfo.updated_at updated_at: userInfo.updated_at
}; };
DatabaseNameService.rememberDatabaseNames({
userDbName: fastProfile.user_db_name,
schoolDbName: fastProfile.school_db_name
});
if (mountedRef.current && !isInitialized) { if (mountedRef.current && !isInitialized) {
setProfile(fastProfile); setProfile(fastProfile);
setLoading(false); setLoading(false);
setIsInitialized(true); setIsInitialized(true);
logger.debug('user-context', '⚡ Fast-path: profile initialized from auth metadata, no spinner'); logger.debug('user-context', '⚡ Fast-path: profile initialized from auth metadata');
} }
// Background: query Supabase profiles for authoritative data (user_db_name, school_db_name, display_name). // Background: query Supabase profiles for authoritative data (user_db_name, school_db_name, display_name).
@ -172,80 +160,15 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
hasSchoolDb: !!profileRow?.school_db_name hasSchoolDb: !!profileRow?.school_db_name
}); });
const metadata = userInfo.user_metadata as CCUserMetadata; const metadata = userInfo.user_metadata as CCUserMetadata;
logger.debug('user-context', '🔧 Step 7: Processing user metadata...', {
hasMetadata: !!metadata,
userType: metadata?.user_type
});
let userDbName = profileRow?.user_db_name ?? null;
let schoolDbName = profileRow?.school_db_name ?? null;
const storedUserDb = DatabaseNameService.getStoredUserDatabase();
const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
// Start provisioning in background (non-blocking)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const provisioningPromise = provisionUser(userInfo.id, authSession?.access_token ?? null)
.then(provisioned => {
if (provisioned) {
logger.debug('user-context', '✅ Provisioning completed in background', {
userDbName: provisioned.user_db_name,
workerDbName: provisioned.worker_db_name
});
// Update localStorage with provisioned values
DatabaseNameService.rememberDatabaseNames({
userDbName: provisioned.user_db_name,
schoolDbName: provisioned.worker_db_name || ''
});
}
})
.catch(provisionError => {
logger.warn('user-context', '⚠️ Background provisioning failed', {
userId: userInfo?.id,
provisionError: provisionError instanceof Error ? provisionError.message : String(provisionError)
});
});
if (!userDbName && storedUserDb) {
userDbName = storedUserDb;
}
if (!schoolDbName && storedSchoolDb) {
schoolDbName = storedSchoolDb;
}
logger.debug('user-context', ' Database name resolution', {
userDbName,
schoolDbName
});
if (!userDbName) {
userDbName = DatabaseNameService.getUserPrivateDB(metadata.user_type || '', userInfo.id);
}
if (!schoolDbName) {
schoolDbName = '';
}
DatabaseNameService.rememberDatabaseNames({
userDbName: String(userDbName || ''),
schoolDbName: String(schoolDbName || '')
});
logger.debug('user-context', '🔧 Creating user profile object...', {
userId: userInfo.id,
userDbName,
schoolDbName,
userType: metadata.user_type
});
const userProfile: CCUser = { const userProfile: CCUser = {
id: userInfo.id, id: userInfo.id,
email: userInfo.email, email: userInfo.email,
user_type: metadata.user_type || '', user_type: metadata.user_type || '',
username: metadata.username || '', username: metadata.username || '',
display_name: String(metadata.display_name || ''), display_name: String(metadata.display_name || ''),
user_db_name: String(userDbName || ''), school_id: (profileRow?.school_id as string) ?? null,
school_db_name: String(schoolDbName || ''),
created_at: userInfo.created_at, created_at: userInfo.created_at,
updated_at: userInfo.updated_at updated_at: userInfo.updated_at
}; };
@ -268,9 +191,7 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
logger.debug('user-context', '✅ User profile loaded', { logger.debug('user-context', '✅ User profile loaded', {
userId: userProfile.id, userId: userProfile.id,
userType: userProfile.user_type, userType: userProfile.user_type,
username: userProfile.username, username: userProfile.username
userDbName: userProfile.user_db_name,
schoolDbName: userProfile.school_db_name
}); });
setError(null); setError(null);
} catch (error) { } catch (error) {
@ -295,22 +216,14 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
user_type: metadata?.user_type || 'email_teacher', user_type: metadata?.user_type || 'email_teacher',
username: metadata?.username || userInfo.email?.split('@')[0] || 'user', username: metadata?.username || userInfo.email?.split('@')[0] || 'user',
display_name: metadata?.display_name || userInfo.email?.split('@')[0] || 'User', display_name: metadata?.display_name || userInfo.email?.split('@')[0] || 'User',
user_db_name: DatabaseNameService.getUserPrivateDB(metadata?.user_type || 'email_teacher', userInfo.id), school_id: null,
school_db_name: '',
created_at: userInfo.created_at, created_at: userInfo.created_at,
updated_at: userInfo.updated_at updated_at: userInfo.updated_at
}; };
DatabaseNameService.rememberDatabaseNames({
userDbName: fallbackProfile.user_db_name,
schoolDbName: fallbackProfile.school_db_name
});
setProfile(fallbackProfile); setProfile(fallbackProfile);
logger.debug('user-context', '✅ Fallback profile created', { logger.debug('user-context', '✅ Fallback profile created', {
userId: fallbackProfile.id, userId: fallbackProfile.id,
userType: fallbackProfile.user_type, userType: fallbackProfile.user_type
userDbName: fallbackProfile.user_db_name
}); });
} else { } else {
setProfile(null); setProfile(null);

View File

@ -0,0 +1,37 @@
import { useState, useEffect } from 'react';
export type DeviceType = 'desktop' | 'tablet' | 'phone' | 'iwb';
function detectDeviceType(): DeviceType {
const width = window.innerWidth;
const touchPoints = navigator.maxTouchPoints ?? 0;
const hasTouch = touchPoints > 0 || 'ontouchstart' in window;
if (width >= 1280 && !hasTouch) return 'desktop';
if (width >= 768 && hasTouch) return 'tablet';
if (width < 768) return 'phone';
return 'desktop';
}
const STORAGE_KEY = 'cc_device_type';
export function useDeviceContext() {
const [deviceType, setDeviceTypeState] = useState<DeviceType>(() => {
const stored = localStorage.getItem(STORAGE_KEY) as DeviceType | null;
if (stored && ['desktop', 'tablet', 'phone', 'iwb'].includes(stored)) return stored;
return detectDeviceType();
});
useEffect(() => {
localStorage.setItem(STORAGE_KEY, deviceType);
}, [deviceType]);
const setDeviceType = (type: DeviceType) => {
setDeviceTypeState(type);
};
const isTouch = deviceType === 'tablet' || deviceType === 'phone';
const isMobileLayout = deviceType === 'phone';
return { deviceType, setDeviceType, isTouch, isMobileLayout };
}

View File

@ -122,7 +122,7 @@ export default function SinglePlayerPage() {
const nodeStoragePath = getNodeStoragePath(context.node); const nodeStoragePath = getNodeStoragePath(context.node);
if (nodeStoragePath) { if (nodeStoragePath) {
logger.debug('single-player-page', '📥 Loading snapshot from database', { logger.debug('single-player-page', '📥 Loading snapshot from database', {
dbName: user.user_db_name, dbName: null,
node: context.node, node: context.node,
node_storage_path: nodeStoragePath, node_storage_path: nodeStoragePath,
user_type: user.user_type, user_type: user.user_type,
@ -131,7 +131,7 @@ export default function SinglePlayerPage() {
await NavigationSnapshotService.loadNodeSnapshotFromDatabase( await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
nodeStoragePath, nodeStoragePath,
user.user_db_name, null,
newStore, newStore,
setLoadingState, setLoadingState,
undefined, // sharedStore undefined, // sharedStore

View File

@ -3,7 +3,6 @@ import { TLUserPreferences } from '@tldraw/tldraw';
import { supabase } from '../../supabaseClient'; import { supabase } from '../../supabaseClient';
import { storageService, StorageKeys } from './localStorageService'; import { storageService, StorageKeys } from './localStorageService';
import { logger } from '../../debugConfig'; import { logger } from '../../debugConfig';
import { DatabaseNameService } from '../graph/databaseNameService';
export interface CCUser { export interface CCUser {
id: string; id: string;
@ -11,8 +10,7 @@ export interface CCUser {
user_type: string; user_type: string;
username: string; username: string;
display_name: string; display_name: string;
user_db_name: string; school_id?: string | null;
school_db_name: string;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
} }
@ -44,28 +42,13 @@ export function convertToCCUser(user: User, metadata: CCUserMetadata): CCUser {
// Default to student if no user type specified // Default to student if no user type specified
const userType = metadata.user_type || 'student'; const userType = metadata.user_type || 'student';
const storedUserDb = DatabaseNameService.getStoredUserDatabase();
const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
const userDbName = storedUserDb || DatabaseNameService.getUserPrivateDB(
userType,
user.id
);
const schoolDbName = metadata.school_db_name || metadata.worker_db_name || storedSchoolDb || '';
DatabaseNameService.rememberDatabaseNames({
userDbName,
schoolDbName
});
return { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
user_type: userType, user_type: userType,
username: username, username,
display_name: displayName, display_name: displayName,
user_db_name: userDbName, school_id: null,
school_db_name: schoolDbName,
created_at: user.created_at, created_at: user.created_at,
updated_at: user.updated_at, updated_at: user.updated_at,
}; };

View File

@ -6,7 +6,6 @@ import { RegistrationResponse } from '../../services/auth/authService';
import { storageService, StorageKeys } from './localStorageService'; import { storageService, StorageKeys } from './localStorageService';
import { logger } from '../../debugConfig'; import { logger } from '../../debugConfig';
import { provisionUser } from '../provisioningService'; import { provisionUser } from '../provisioningService';
import { DatabaseNameService } from '../graph/databaseNameService';
const REGISTRATION_SERVICE = 'registration-service'; const REGISTRATION_SERVICE = 'registration-service';
@ -87,14 +86,6 @@ export class RegistrationService {
try { try {
const provisioned = await provisionUser(ccUser.id, provisioningToken); const provisioned = await provisionUser(ccUser.id, provisioningToken);
if (provisioned) { if (provisioned) {
ccUser.user_db_name = provisioned.user_db_name;
if (provisioned.worker_db_name) {
ccUser.school_db_name = provisioned.worker_db_name;
}
DatabaseNameService.rememberDatabaseNames({
userDbName: ccUser.user_db_name,
schoolDbName: ccUser.school_db_name
});
logger.info(REGISTRATION_SERVICE, '✅ Provisioning successful', { logger.info(REGISTRATION_SERVICE, '✅ Provisioning successful', {
userId: ccUser.id, userId: ccUser.id,
userDbName: provisioned.user_db_name, userDbName: provisioned.user_db_name,
@ -110,11 +101,6 @@ export class RegistrationService {
}); });
} }
DatabaseNameService.rememberDatabaseNames({
userDbName: ccUser.user_db_name,
schoolDbName: ccUser.school_db_name
});
return { return {
user: ccUser, user: ccUser,
accessToken: authData.session?.access_token || null, accessToken: authData.session?.access_token || null,

View File

@ -281,8 +281,12 @@ export class NavigationSnapshotService {
throw new Error('No user found'); throw new Error('No user found');
} }
const dbName = user.user_db_name; const dbName = (user as (typeof user & { user_db_name?: string })).user_db_name ?? '';
if (!dbName) {
logger.debug('snapshot-service', '⚠️ No db name - snapshot save skipped (Phase B will migrate to Supabase Storage)');
return;
}
logger.debug('snapshot-service', '💾 Saving snapshot', { logger.debug('snapshot-service', '💾 Saving snapshot', {
nodePath, nodePath,
dbName, dbName,
@ -315,8 +319,12 @@ export class NavigationSnapshotService {
throw new Error('No user found'); throw new Error('No user found');
} }
const dbName = user.user_db_name; const dbName = (user as (typeof user & { user_db_name?: string })).user_db_name ?? '';
if (!dbName) {
logger.debug('snapshot-service', '⚠️ No db name - snapshot load skipped (Phase B will migrate to Supabase Storage)');
return;
}
logger.debug('snapshot-service', '📥 Loading snapshot', { logger.debug('snapshot-service', '📥 Loading snapshot', {
nodePath: node.node_storage_path, nodePath: node.node_storage_path,
dbName, dbName,

View File

@ -249,11 +249,9 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
const { completedSegments, currentSegment, activeSession, wordCount } = get(); const { completedSegments, currentSegment, activeSession, wordCount } = get();
if (isFinal) { if (isFinal) {
// Final segment - move current to completed, clear current // Final segment — append the finalized text directly (not currentSegment, which
const newCompleted = [...completedSegments]; // may lag behind or duplicate when WhisperLive re-sends the full segments array).
if (currentSegment && currentSegment.text.trim()) { const newCompleted = [...completedSegments, { text, isFinal: true, ...metadata }];
newCompleted.push({ ...currentSegment, isFinal: true });
}
const newWordCount = newCompleted.reduce( const newWordCount = newCompleted.reduce(
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
0 0

View File

@ -14,6 +14,7 @@ export class TranscriptionService {
private mediaStreamSource: MediaStreamAudioSourceNode | null = null; private mediaStreamSource: MediaStreamAudioSourceNode | null = null;
private workletNode: AudioWorkletNode | null = null; private workletNode: AudioWorkletNode | null = null;
private selectedDeviceId: string = ''; private selectedDeviceId: string = '';
private finalizedSegmentCount: number = 0;
private onTranscriptionUpdate: ((text: string, isFinal: boolean, metadata: { start: number, end: number }) => void) | null = null; private onTranscriptionUpdate: ((text: string, isFinal: boolean, metadata: { start: number, end: number }) => void) | null = null;
constructor(deviceId: string = '') { constructor(deviceId: string = '') {
@ -32,7 +33,7 @@ export class TranscriptionService {
// Call getUserMedia directly — this triggers the browser permission prompt. // Call getUserMedia directly — this triggers the browser permission prompt.
// The old code called enumerateDevices() first to find a device ID, but // The old code called enumerateDevices() first to find a device ID, but
// without microphone permission deviceId is always "" (empty string, falsy), // without microphone permission deviceId is always (empty string, falsy),
// causing an early return that never prompted the user for permission. // causing an early return that never prompted the user for permission.
const audioConstraints: MediaTrackConstraints = this.selectedDeviceId const audioConstraints: MediaTrackConstraints = this.selectedDeviceId
? { deviceId: { exact: this.selectedDeviceId } } ? { deviceId: { exact: this.selectedDeviceId } }
@ -58,18 +59,15 @@ export class TranscriptionService {
ws.onopen = () => { ws.onopen = () => {
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
logger.info('transcription-service', '✅ WebSocket connected'); logger.info('transcription-service', '✅ WebSocket connected');
// Send initial configuration message // Send initial configuration — audio capture starts only after SERVER_READY.
const message = JSON.stringify({ ws.send(JSON.stringify({
uid: uuid, uid: uuid,
language: config.language || 'en', language: config.language || 'en',
task: config.task || 'transcribe', task: config.task || 'transcribe',
model: config.modelSize || 'small', model: config.modelSize || 'base',
use_vad: config.useVad ?? true, use_vad: config.useVad ?? true,
}); }));
ws.send(message);
this.setupAudioProcessing();
}; };
ws.onerror = (error) => { ws.onerror = (error) => {
@ -87,6 +85,13 @@ export class TranscriptionService {
return; 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;
}
if (data.status === 'WAIT') { if (data.status === 'WAIT') {
logger.info('transcription-service', `⏳ Wait time: ${Math.round(data.message)} minutes`); logger.info('transcription-service', `⏳ Wait time: ${Math.round(data.message)} minutes`);
this.cleanup(); this.cleanup();
@ -99,39 +104,27 @@ export class TranscriptionService {
return; return;
} }
if (this.onTranscriptionUpdate && data.segments) { if (this.onTranscriptionUpdate && data.segments && data.segments.length > 0) {
// Get the last segment which is the current one being updated const segments = data.segments;
const lastSegment = data.segments[data.segments.length - 1]; const lastIdx = segments.length - 1;
// Process completed segments // Only emit segments we have not finalized yet — avoids re-processing the
let lastCompletedText = ''; // full array on every message (which caused the stuck last segment bug).
for (let i = 0; i < data.segments.length - 1; i++) { for (let i = this.finalizedSegmentCount; i < lastIdx; i++) {
const segment = data.segments[i]; const seg = segments[i];
// Only send update if this segment is different from the last one this.onTranscriptionUpdate(seg.text, true, {
if (segment.text.trim() !== lastCompletedText.trim()) { start: parseFloat(seg.start),
this.onTranscriptionUpdate( end: parseFloat(seg.end),
segment.text, });
segment.completed ?? true, // Server marks completed segments this.finalizedSegmentCount = i + 1;
{
start: parseFloat(segment.start),
end: parseFloat(segment.end)
}
);
lastCompletedText = segment.text;
}
} }
// Update the current (incomplete) segment only if it's different from the last completed one // Always update the live (last) segment
if (lastSegment && lastSegment.text.trim() !== lastCompletedText.trim()) { const lastSeg = segments[lastIdx];
this.onTranscriptionUpdate( this.onTranscriptionUpdate(lastSeg.text, lastSeg.completed ?? false, {
lastSegment.text, start: parseFloat(lastSeg.start),
lastSegment.completed ?? false, // Last segment is typically incomplete unless marked otherwise end: parseFloat(lastSeg.end),
{ });
start: parseFloat(lastSegment.start),
end: parseFloat(lastSegment.end)
}
);
}
} }
}; };
} catch (error) { } catch (error) {
@ -146,19 +139,21 @@ export class TranscriptionService {
} }
try { try {
this.audioContext = new AudioContext(); // Request 16 kHz from the browser — it resamples natively so we send
// the correct rate to the server without any JS resampling overhead.
// Load and register the audio worklet this.audioContext = new AudioContext({ sampleRate: 16000 });
await this.audioContext.audioWorklet.addModule('/audioWorklet.js'); await this.audioContext.audioWorklet.addModule('/audioWorklet.js');
this.mediaStreamSource = this.audioContext.createMediaStreamSource(this.stream); this.mediaStreamSource = this.audioContext.createMediaStreamSource(this.stream);
this.workletNode = new AudioWorkletNode(this.audioContext, 'audio-processor'); this.workletNode = new AudioWorkletNode(this.audioContext, 'audio-processor');
// Handle audio data from the worklet // 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) => { this.workletNode.port.onmessage = (event) => {
if (this.socket?.readyState === WebSocket.OPEN) { if (this.socket?.readyState === WebSocket.OPEN) {
const resampledData = this.resampleTo16kHZ(event.data, this.audioContext!.sampleRate); this.socket.send(event.data); // event.data is a transferred ArrayBuffer
this.socket.send(resampledData);
} }
}; };
@ -169,27 +164,16 @@ export class TranscriptionService {
} }
} }
private resampleTo16kHZ(audioData: Float32Array, origSampleRate: number): Float32Array {
const ratio = origSampleRate / 16000;
const newLength = Math.round(audioData.length / ratio);
const result = new Float32Array(newLength);
for (let i = 0; i < newLength; i++) {
const pos = i * ratio;
const leftPos = Math.floor(pos);
const rightPos = Math.ceil(pos);
const weight = pos - leftPos;
result[i] = audioData[leftPos] * (1 - weight) + (audioData[rightPos] || 0) * weight;
}
return result;
}
stopTranscription() { stopTranscription() {
// Signal the server cleanly so it can finalise the last segment.
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send('END_OF_AUDIO');
}
this.cleanup(); this.cleanup();
} }
private cleanup() { private cleanup() {
this.finalizedSegmentCount = 0;
if (this.workletNode) { if (this.workletNode) {
this.workletNode.disconnect(); this.workletNode.disconnect();
this.workletNode = null; this.workletNode = null;

View File

@ -365,7 +365,7 @@ export const CCTranscriptionPanel: React.FC = () => {
fontSize: "14px", fontSize: "14px",
fontWeight: 600, fontWeight: 600,
color: "#fff", color: "#fff",
backgroundColor: isRecording ? "#ef4444" : "var(--color-text)", backgroundColor: isRecording ? "#ef4444" : "#2563eb",
transition: "background-color 200ms ease", transition: "background-color 200ms ease",
}} }}
> >
@ -538,7 +538,7 @@ export const CCTranscriptionPanel: React.FC = () => {
key={"completed-" + i} key={"completed-" + i}
style={{ style={{
padding: "8px 10px", padding: "8px 10px",
backgroundColor: "#fff", backgroundColor: "var(--color-muted)",
borderRadius: "4px", borderRadius: "4px",
border: "1px solid var(--color-divider)", border: "1px solid var(--color-divider)",
fontSize: "13px", fontSize: "13px",
@ -614,7 +614,7 @@ export const CCTranscriptionPanel: React.FC = () => {
key={session.id} key={session.id}
style={{ style={{
padding: "10px", padding: "10px",
backgroundColor: "#fff", backgroundColor: "var(--color-muted)",
borderRadius: "4px", borderRadius: "4px",
border: "1px solid var(--color-divider)", border: "1px solid var(--color-divider)",
}} }}
@ -694,7 +694,7 @@ export const CCTranscriptionPanel: React.FC = () => {
padding: "6px 10px", padding: "6px 10px",
border: "none", border: "none",
borderRadius: "4px", borderRadius: "4px",
backgroundColor: newKeyword.trim() ? "var(--color-text)" : "var(--color-divider)", backgroundColor: newKeyword.trim() ? "#2563eb" : "var(--color-divider)",
color: "#fff", color: "#fff",
cursor: newKeyword.trim() ? "pointer" : "not-allowed", cursor: newKeyword.trim() ? "pointer" : "not-allowed",
fontSize: "13px", fontSize: "13px",
@ -722,7 +722,7 @@ export const CCTranscriptionPanel: React.FC = () => {
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
padding: "6px 10px", padding: "6px 10px",
backgroundColor: "#fff", backgroundColor: "var(--color-muted)",
borderRadius: "4px", borderRadius: "4px",
border: "1px solid var(--color-divider)", border: "1px solid var(--color-divider)",
}} }}