Compare commits
No commits in common. "7b546c933ed8d70fcc4de8a136a6e616bc37b4ac" and "03452582476fcd6e1e9cbffb7203e0f6e5b4c57d" have entirely different histories.
7b546c933e
...
0345258247
@ -1,26 +1,12 @@
|
|||||||
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 samples = input[0];
|
const audioData = input[0];
|
||||||
for (let i = 0; i < samples.length; i++) {
|
this.port.postMessage(audioData);
|
||||||
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);
|
||||||
@ -4,6 +4,8 @@ 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';
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ 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 {
|
||||||
@ -51,13 +52,20 @@ 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,
|
||||||
school_id: null,
|
user_db_name: userDbName,
|
||||||
|
school_db_name: schoolDbName,
|
||||||
created_at: supabaseUser.created_at,
|
created_at: supabaseUser.created_at,
|
||||||
updated_at: supabaseUser.updated_at
|
updated_at: supabaseUser.updated_at
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,6 +4,8 @@ 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 {
|
||||||
@ -110,23 +112,33 @@ 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 immediately (no spinner on refresh).
|
// Fast-path: build profile from auth metadata + localStorage immediately.
|
||||||
|
// 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 || ''),
|
||||||
school_id: null,
|
user_db_name: String(fastUserDb || ''),
|
||||||
|
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');
|
logger.debug('user-context', '⚡ Fast-path: profile initialized from auth metadata, no spinner');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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).
|
||||||
@ -160,15 +172,80 @@ 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 || ''),
|
||||||
school_id: (profileRow?.school_id as string) ?? null,
|
user_db_name: String(userDbName || ''),
|
||||||
|
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
|
||||||
};
|
};
|
||||||
@ -191,7 +268,9 @@ 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) {
|
||||||
@ -216,14 +295,22 @@ 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',
|
||||||
school_id: null,
|
user_db_name: DatabaseNameService.getUserPrivateDB(metadata?.user_type || 'email_teacher', userInfo.id),
|
||||||
|
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);
|
||||||
|
|||||||
@ -1,37 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@ -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: null,
|
dbName: user.user_db_name,
|
||||||
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,
|
||||||
null,
|
user.user_db_name,
|
||||||
newStore,
|
newStore,
|
||||||
setLoadingState,
|
setLoadingState,
|
||||||
undefined, // sharedStore
|
undefined, // sharedStore
|
||||||
|
|||||||
@ -3,6 +3,7 @@ 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;
|
||||||
@ -10,7 +11,8 @@ export interface CCUser {
|
|||||||
user_type: string;
|
user_type: string;
|
||||||
username: string;
|
username: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
school_id?: string | null;
|
user_db_name: string;
|
||||||
|
school_db_name: string;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
@ -42,13 +44,28 @@ 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,
|
||||||
school_id: null,
|
user_db_name: userDbName,
|
||||||
|
school_db_name: schoolDbName,
|
||||||
created_at: user.created_at,
|
created_at: user.created_at,
|
||||||
updated_at: user.updated_at,
|
updated_at: user.updated_at,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,6 +6,7 @@ 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';
|
||||||
|
|
||||||
@ -86,6 +87,14 @@ 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,
|
||||||
@ -101,6 +110,11 @@ 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,
|
||||||
|
|||||||
@ -281,12 +281,8 @@ export class NavigationSnapshotService {
|
|||||||
throw new Error('No user found');
|
throw new Error('No user found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbName = (user as (typeof user & { user_db_name?: string })).user_db_name ?? '';
|
const dbName = user.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,
|
||||||
@ -319,12 +315,8 @@ export class NavigationSnapshotService {
|
|||||||
throw new Error('No user found');
|
throw new Error('No user found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbName = (user as (typeof user & { user_db_name?: string })).user_db_name ?? '';
|
const dbName = user.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,
|
||||||
|
|||||||
@ -249,9 +249,11 @@ 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 — append the finalized text directly (not currentSegment, which
|
// Final segment - move current to completed, clear current
|
||||||
// may lag behind or duplicate when WhisperLive re-sends the full segments array).
|
const newCompleted = [...completedSegments];
|
||||||
const newCompleted = [...completedSegments, { text, isFinal: true, ...metadata }];
|
if (currentSegment && currentSegment.text.trim()) {
|
||||||
|
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
|
||||||
|
|||||||
@ -14,7 +14,6 @@ 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 = '') {
|
||||||
@ -33,7 +32,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 } }
|
||||||
@ -59,15 +58,18 @@ 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 — audio capture starts only after SERVER_READY.
|
// Send initial configuration message
|
||||||
ws.send(JSON.stringify({
|
const message = 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 || 'base',
|
model: config.modelSize || 'small',
|
||||||
use_vad: config.useVad ?? true,
|
use_vad: config.useVad ?? true,
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
ws.send(message);
|
||||||
|
this.setupAudioProcessing();
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
ws.onerror = (error) => {
|
||||||
@ -85,13 +87,6 @@ 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();
|
||||||
@ -104,27 +99,39 @@ export class TranscriptionService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.onTranscriptionUpdate && data.segments && data.segments.length > 0) {
|
if (this.onTranscriptionUpdate && data.segments) {
|
||||||
const segments = data.segments;
|
// Get the last segment which is the current one being updated
|
||||||
const lastIdx = segments.length - 1;
|
const lastSegment = data.segments[data.segments.length - 1];
|
||||||
|
|
||||||
// Only emit segments we have not finalized yet — avoids re-processing the
|
// Process completed segments
|
||||||
// full array on every message (which caused the stuck last segment bug).
|
let lastCompletedText = '';
|
||||||
for (let i = this.finalizedSegmentCount; i < lastIdx; i++) {
|
for (let i = 0; i < data.segments.length - 1; i++) {
|
||||||
const seg = segments[i];
|
const segment = data.segments[i];
|
||||||
this.onTranscriptionUpdate(seg.text, true, {
|
// Only send update if this segment is different from the last one
|
||||||
start: parseFloat(seg.start),
|
if (segment.text.trim() !== lastCompletedText.trim()) {
|
||||||
end: parseFloat(seg.end),
|
this.onTranscriptionUpdate(
|
||||||
});
|
segment.text,
|
||||||
this.finalizedSegmentCount = i + 1;
|
segment.completed ?? true, // Server marks completed segments
|
||||||
|
{
|
||||||
|
start: parseFloat(segment.start),
|
||||||
|
end: parseFloat(segment.end)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
lastCompletedText = segment.text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always update the live (last) segment
|
// Update the current (incomplete) segment only if it's different from the last completed one
|
||||||
const lastSeg = segments[lastIdx];
|
if (lastSegment && lastSegment.text.trim() !== lastCompletedText.trim()) {
|
||||||
this.onTranscriptionUpdate(lastSeg.text, lastSeg.completed ?? false, {
|
this.onTranscriptionUpdate(
|
||||||
start: parseFloat(lastSeg.start),
|
lastSegment.text,
|
||||||
end: parseFloat(lastSeg.end),
|
lastSegment.completed ?? false, // Last segment is typically incomplete unless marked otherwise
|
||||||
});
|
{
|
||||||
|
start: parseFloat(lastSegment.start),
|
||||||
|
end: parseFloat(lastSegment.end)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -139,21 +146,19 @@ export class TranscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Request 16 kHz from the browser — it resamples natively so we send
|
this.audioContext = new AudioContext();
|
||||||
// the correct rate to the server without any JS resampling overhead.
|
|
||||||
this.audioContext = new AudioContext({ sampleRate: 16000 });
|
// Load and register the audio worklet
|
||||||
|
|
||||||
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');
|
||||||
|
|
||||||
// The worklet accumulates 4096 samples (256 ms at 16 kHz) before posting,
|
// Handle audio data from the worklet
|
||||||
// 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) {
|
||||||
this.socket.send(event.data); // event.data is a transferred ArrayBuffer
|
const resampledData = this.resampleTo16kHZ(event.data, this.audioContext!.sampleRate);
|
||||||
|
this.socket.send(resampledData);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -164,16 +169,27 @@ export class TranscriptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stopTranscription() {
|
private resampleTo16kHZ(audioData: Float32Array, origSampleRate: number): Float32Array {
|
||||||
// Signal the server cleanly so it can finalise the last segment.
|
const ratio = origSampleRate / 16000;
|
||||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
const newLength = Math.round(audioData.length / ratio);
|
||||||
this.socket.send('END_OF_AUDIO');
|
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() {
|
||||||
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;
|
||||||
|
|||||||
@ -365,7 +365,7 @@ export const CCTranscriptionPanel: React.FC = () => {
|
|||||||
fontSize: "14px",
|
fontSize: "14px",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
backgroundColor: isRecording ? "#ef4444" : "#2563eb",
|
backgroundColor: isRecording ? "#ef4444" : "var(--color-text)",
|
||||||
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: "var(--color-muted)",
|
backgroundColor: "#fff",
|
||||||
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: "var(--color-muted)",
|
backgroundColor: "#fff",
|
||||||
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() ? "#2563eb" : "var(--color-divider)",
|
backgroundColor: newKeyword.trim() ? "var(--color-text)" : "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: "var(--color-muted)",
|
backgroundColor: "#fff",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
border: "1px solid var(--color-divider)",
|
border: "1px solid var(--color-divider)",
|
||||||
}}
|
}}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user