+ {/*
+ Live caption overlay (§12). In-memory only, dropped when the call ends (D9). Each line shows
+ the translation as the main line with the original underneath, marked as on-device machine
+ translation; an untranslatable pair shows the original with "Translation unavailable". No
+ caption text is ever logged or sent to the backend — it only rode the E2EE control channel.
+ */}
+ {showCaptionControls && captions && (captionsOn || peerCaptioning || captions.lines.length > 0) && (
+
+ {captions.lines.length === 0 ? (
+
Listening…
+ ) : (
+ captions.lines.slice(-6).map((line) => {
+ const translated =
+ line.translationState === 'done' && line.translatedText && line.translatedText !== line.text;
+ return (
+
+
+ {line.source === 'local' ? 'You' : peerName}
+
+
+ {translated ? line.translatedText : line.text}
+
+ {translated && (
+
+ {line.text}
+ · Translated on your device · may be inaccurate
+
+ )}
+ {line.translationState === 'unavailable' && (
+ Translation unavailable
+ )}
+
+ );
+ })
+ )}
+
+ )}
+
{/* Controls */}
)}
+ {showCaptionControls && (
+ <>
+
+ {captionsOn ? : }
+
+
+ {/* Target-language menu (§12.5 / D12). A menu_item: the chosen language code rides as the
+ telemetry targetId — never caption text. The on-device engine decides availability. */}
+
+ >
+ )}
+
+ {/*
+ Calls — "Help translate my captions" (OneHookBackend/docs/voice-video-calls.md
+ D13). When on, this device may translate its OWN live call captions on-device for a
+ partner whose device cannot do the language pair (the §12.5 sender fallback). It is
+ on-device only and never sends caption text anywhere; default off because it uses
+ this device's battery and may download translation models.
+ */}
+
+
+
+
+
@@ -1301,40 +1296,6 @@ function MatchConversation({
onClose={() => setRecoveryOpen(false)}
onRecovered={() => void refetch()}
/>
-
- {/* Incoming-call ring + the in-call surface for this conversation. */}
- {call.incoming && !call.active && (
-
void call.acceptIncoming('VIDEO')}
- onAcceptAudio={() => void call.acceptIncoming('AUDIO')}
- onDecline={() => void call.declineIncoming()}
- />
- )}
- {call.active && (
- void call.startScreenShare()}
- onStopScreenShare={call.stopScreenShare}
- onAcceptScreenShare={call.acceptScreenShare}
- onDeclineScreenShare={call.declineScreenShare}
- onHangup={() => void call.hangup()}
- />
- )}
>
);
}
diff --git a/src/hooks/use-calls.ts b/src/hooks/use-calls.ts
index 6dd605c..2f1e4af 100644
--- a/src/hooks/use-calls.ts
+++ b/src/hooks/use-calls.ts
@@ -1,331 +1,23 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { CallApi, type Call, type CallEndReason, type CallEvent, type CallMedia } from '../api/calls';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { CallApi } from '../api/calls';
import {
CallEligibilityTracker,
callLockMessage,
DEFAULT_ELIGIBILITY,
type CallEligibilityState,
} from '../lib/call-eligibility';
-import { ChatEncryptionManager } from '../lib/chat-encryption';
-import {
- CallSession,
- type CallSessionDeps,
- type CallSessionHandlers,
- type CallSessionState,
- type ScreenShareState,
-} from '../lib/call-session';
-import { friendlyCallError } from '../lib/call-errors';
-
-/** A pending incoming call shown by the ring modal before the user accepts or declines. */
-export interface IncomingCall {
- callId: string;
- media: CallMedia;
- callerDeviceId?: string;
-}
-
-export interface CallController {
- /** True while a call is being set up or is live (ringing out, connecting, connected). */
- active: boolean;
- state: CallSessionState;
- media: CallMedia;
- incoming: IncomingCall | null;
- localStream: MediaStream | null;
- remoteStream: MediaStream | null;
- screenStream: MediaStream | null;
- sas: string[] | null;
- muted: boolean;
- cameraOff: boolean;
- screenShareState: ScreenShareState;
- /** A peer screen-share request awaiting the local user's accept/decline (§11.5). */
- screenRequest: string | null;
- canSendScreen: boolean;
- /** The last call's end reason, for a one-shot toast. Cleared after reading via {@link clearEnded}. */
- lastEndReason: CallEndReason | null;
- /** Transient user-facing error copy (mapped via friendlyCallError). */
- error: string | null;
- /** Server call rows for this match, newest first, for the conversation timeline. */
- history: Call[];
- startCall(media: CallMedia): Promise;
- acceptIncoming(media?: CallMedia): Promise;
- declineIncoming(): Promise;
- hangup(): Promise;
- toggleMute(): void;
- toggleCamera(): void;
- startScreenShare(): Promise;
- stopScreenShare(): void;
- acceptScreenShare(): void;
- declineScreenShare(): void;
- clearError(): void;
- clearEnded(): void;
-}
/**
- * Manages voice/video calls for ONE open conversation: subscribes to `onCallEvent(userId)`, filters
- * to this match, drives a {@link CallSession}, and surfaces everything the call UI needs as React
- * state. Scoped to the open conversation (web rings only while a tab holds the socket, per §10).
+ * Call hooks for a conversation.
+ *
+ * The call LIFECYCLE (ringing, in-call surface, the `onCallEvent` subscription and the WebRTC
+ * `CallSession`) is owned by the ONE app-level controller — see `GlobalCallController` /
+ * `CallProvider` — so a call rings on any screen, not only while a conversation is open
+ * (OneHookBackend/docs/voice-video-calls.md §10). A conversation starts calls through `useCall()`.
*
- * The browser WebRTC primitives are the only non-injected part here; the session itself takes them as
- * dependencies so it stays unit-testable (see call-session.test.ts).
+ * This module now only owns the per-conversation "may I call yet?" gate, which is conversation-scoped
+ * by nature (it depends on whether the two participants have both messaged).
*/
-export function useCallController(params: {
- currentUserId: string;
- matchId: string;
- peerId?: string;
- peerName: string;
-}): CallController {
- const { currentUserId, matchId, peerId, peerName } = params;
-
- const [active, setActive] = useState(false);
- const [state, setState] = useState('idle');
- const [media, setMedia] = useState('AUDIO');
- const [incoming, setIncoming] = useState(null);
- const [localStream, setLocalStream] = useState(null);
- const [remoteStream, setRemoteStream] = useState(null);
- const [screenStream, setScreenStream] = useState(null);
- const [sas, setSas] = useState(null);
- const [muted, setMuted] = useState(false);
- const [cameraOff, setCameraOff] = useState(false);
- const [screenShareState, setScreenShareState] = useState('none');
- const [screenRequest, setScreenRequest] = useState(null);
- const [lastEndReason, setLastEndReason] = useState(null);
- const [error, setError] = useState(null);
- const [history, setHistory] = useState([]);
-
- const sessionRef = useRef(null);
-
- const encryptionManager = useMemo(
- () => (currentUserId ? new ChatEncryptionManager(currentUserId) : null),
- [currentUserId]
- );
-
- const canSendScreen =
- typeof navigator !== 'undefined' &&
- typeof navigator.mediaDevices?.getDisplayMedia === 'function';
-
- const refreshHistory = useCallback(() => {
- if (!matchId) return;
- CallApi.getCallHistory(matchId)
- .then(setHistory)
- .catch(() => undefined); // best effort: a missing history must never break the conversation
- }, [matchId]);
-
- useEffect(() => {
- refreshHistory();
- }, [refreshHistory]);
-
- const resetCallState = useCallback(() => {
- sessionRef.current = null;
- setActive(false);
- setState('idle');
- setLocalStream(null);
- setRemoteStream(null);
- setScreenStream(null);
- setSas(null);
- setMuted(false);
- setCameraOff(false);
- setScreenShareState('none');
- setScreenRequest(null);
- }, []);
-
- const handlers: CallSessionHandlers = useMemo(
- () => ({
- onStateChange: setState,
- onLocalStream: setLocalStream,
- onRemoteStream: (s) => setRemoteStream(s),
- onScreenStream: setScreenStream,
- onSasChange: setSas,
- onScreenShareRequest: (id) => setScreenRequest(id),
- onScreenShareStateChange: setScreenShareState,
- onMuteChange: setMuted,
- onCameraChange: setCameraOff,
- onError: (e) => setError(friendlyCallError(e)),
- onEnded: (reason) => {
- setLastEndReason(reason);
- resetCallState();
- refreshHistory();
- },
- }),
- [resetCallState, refreshHistory]
- );
-
- const makeDeps = useCallback((): CallSessionDeps | null => {
- if (!encryptionManager || typeof navigator === 'undefined' || !navigator.mediaDevices) {
- return null;
- }
- const deps: CallSessionDeps = {
- api: CallApi,
- crypto: encryptionManager,
- createPeerConnection: (config) => new RTCPeerConnection(config),
- getUserMedia: (constraints) => navigator.mediaDevices.getUserMedia(constraints),
- };
- if (typeof navigator.mediaDevices.getDisplayMedia === 'function') {
- deps.getDisplayMedia = (constraints) => navigator.mediaDevices.getDisplayMedia(constraints);
- }
- return deps;
- }, [encryptionManager]);
-
- const startCall = useCallback(
- async (callMedia: CallMedia) => {
- if (sessionRef.current || !peerId) return;
- const deps = makeDeps();
- if (!deps) {
- setError('Calling is not available in this browser.');
- return;
- }
- setError(null);
- setMedia(callMedia);
- const session = new CallSession(deps, {
- role: 'caller',
- selfUserId: currentUserId,
- peerId,
- matchId,
- media: callMedia,
- handlers,
- });
- sessionRef.current = session;
- setActive(true);
- await session.start();
- },
- [makeDeps, peerId, currentUserId, matchId, handlers]
- );
-
- const acceptIncoming = useCallback(
- async (callMedia?: CallMedia) => {
- if (!incoming || sessionRef.current || !peerId) return;
- const deps = makeDeps();
- if (!deps) {
- setError('Calling is not available in this browser.');
- return;
- }
- const chosen = callMedia ?? incoming.media;
- setError(null);
- setMedia(chosen);
- const session = new CallSession(deps, {
- role: 'callee',
- selfUserId: currentUserId,
- peerId,
- matchId,
- media: chosen,
- callId: incoming.callId,
- callerDeviceId: incoming.callerDeviceId,
- handlers,
- });
- sessionRef.current = session;
- setActive(true);
- setIncoming(null);
- await session.accept(chosen);
- },
- [incoming, makeDeps, peerId, currentUserId, matchId, handlers]
- );
-
- const declineIncoming = useCallback(async () => {
- const pending = incoming;
- setIncoming(null);
- if (!pending) return;
- try {
- await CallApi.declineCall(pending.callId);
- } catch (e) {
- setError(friendlyCallError(e));
- } finally {
- refreshHistory();
- }
- }, [incoming, refreshHistory]);
-
- const hangup = useCallback(async () => {
- await sessionRef.current?.hangup();
- }, []);
-
- const toggleMute = useCallback(() => sessionRef.current?.toggleMute(), []);
- const toggleCamera = useCallback(() => sessionRef.current?.toggleCamera(), []);
- const startScreenShare = useCallback(async () => {
- try {
- await sessionRef.current?.startScreenShare();
- } catch (e) {
- setError(friendlyCallError(e));
- }
- }, []);
- const stopScreenShare = useCallback(() => sessionRef.current?.stopScreenShare(), []);
- const acceptScreenShare = useCallback(() => {
- if (screenRequest) {
- sessionRef.current?.acceptScreenShare(screenRequest);
- setScreenRequest(null);
- }
- }, [screenRequest]);
- const declineScreenShare = useCallback(() => {
- if (screenRequest) {
- sessionRef.current?.declineScreenShare(screenRequest);
- setScreenRequest(null);
- }
- }, [screenRequest]);
-
- const clearError = useCallback(() => setError(null), []);
- const clearEnded = useCallback(() => setLastEndReason(null), []);
-
- // Subscribe to this user's call events, scoped to the open conversation.
- useEffect(() => {
- if (!currentUserId) return;
- const unsubscribe = CallApi.subscribeToCallEvents(currentUserId, (event: CallEvent) => {
- if (event.kind === 'INCOMING') {
- if (event.matchId !== matchId) return; // only ring for the conversation that is open
- if (sessionRef.current) return; // already in / setting up a call → the backend returns BUSY
- setIncoming({
- callId: event.callId,
- media: event.media ?? 'AUDIO',
- callerDeviceId: event.fromDeviceId ?? undefined,
- });
- return;
- }
- const session = sessionRef.current;
- if (session && (!session.getCallId() || session.getCallId() === event.callId)) {
- void session.handleEvent(event);
- }
- });
- return () => unsubscribe();
- }, [currentUserId, matchId]);
-
- // Clear a stale incoming prompt (and tear down any call) when the conversation changes / unmounts.
- useEffect(() => {
- return () => {
- void sessionRef.current?.hangup();
- sessionRef.current = null;
- setIncoming(null);
- };
- }, [matchId]);
-
- // `peerName` is part of the public contract (the UI labels the modal with it) but not used here.
- void peerName;
-
- return {
- active,
- state,
- media,
- incoming,
- localStream,
- remoteStream,
- screenStream,
- sas,
- muted,
- cameraOff,
- screenShareState,
- screenRequest,
- canSendScreen,
- lastEndReason,
- error,
- history,
- startCall,
- acceptIncoming,
- declineIncoming,
- hangup,
- toggleMute,
- toggleCamera,
- startScreenShare,
- stopScreenShare,
- acceptScreenShare,
- declineScreenShare,
- clearError,
- clearEnded,
- };
-}
/** What the call buttons need to render and police the "message first" lock. */
export interface CallEligibilityController {
diff --git a/src/lib/analytics/generated/telemetryCatalog.ts b/src/lib/analytics/generated/telemetryCatalog.ts
index 08ea0af..f245840 100644
--- a/src/lib/analytics/generated/telemetryCatalog.ts
+++ b/src/lib/analytics/generated/telemetryCatalog.ts
@@ -1,15 +1,15 @@
// GENERATED FILE - DO NOT EDIT.
-// telemetry-integrity: sha256 0df1cf4b23f2ea22d4a010fcb1526136c846b6728147068b6dc96d5bb98b1de8
+// telemetry-integrity: sha256 f1294f8ce7f457bf0ac4251cd697cd49d08caa9229e480d70045d516d517c8f5
// Source of truth: OneHookBackend/packages/telemetry/src/catalog.ts
// Regenerate: (OneHookBackend) npm run telemetry:generate && npm run telemetry:sync
-// Catalog version 1.3.0, hash 47d859d40ce38e04.
+// Catalog version 1.3.0, hash 9db35444cda2b7ba.
//
// HARD RULE: every user interaction MUST emit ui_interaction with an id from THIS file, and
// ids are controlled by the backend. Read TELEMETRY_CONTRACT.txt (printed by every build).
/* eslint-disable */
export const TELEMETRY_CATALOG_VERSION = "1.3.0";
-export const TELEMETRY_CATALOG_HASH = "47d859d40ce38e04";
+export const TELEMETRY_CATALOG_HASH = "9db35444cda2b7ba";
/** Every signal type the ingest accepts. */
export const SignalType = {
@@ -317,6 +317,9 @@ export const UiElement = {
CALL_SPEAKER: "call.speaker",
CALL_SCREEN_SHARE: "call.screen_share",
CALL_SCREEN_SHARE_RESPOND: "call.screen_share_respond",
+ CALL_CAPTIONS: "call.captions",
+ CALL_CAPTION_LANGUAGE_SELECT: "call.caption_language_select",
+ CHAT_SETTINGS_HELP_TRANSLATE_CAPTIONS: "chat_settings.help_translate_captions",
CHAT_MEDIA_OPEN: "chat.media_open",
CHAT_AUDIO_PLAY: "chat.audio_play",
CHAT_FILE_OPEN: "chat.file_open",
@@ -550,8 +553,11 @@ export const UI_ELEMENTS: { readonly [K in UiElementId]: UiElementSpec } = {
"call.camera": { screen: "CALL", kind: "toggle", actions: ["toggle"], values: ["on", "off"], platforms: ["web", "ios", "android"] },
"call.camera_switch": { screen: "CALL", kind: "button", actions: ["tap"], values: null, platforms: ["ios"] },
"call.speaker": { screen: "CALL", kind: "toggle", actions: ["toggle"], values: ["on", "off"], platforms: ["ios", "android"] },
- "call.screen_share": { screen: "CALL", kind: "toggle", actions: ["toggle"], values: ["on", "off"], platforms: ["web", "android"] },
- "call.screen_share_respond": { screen: "CALL", kind: "dialog_button", actions: ["tap", "dismiss"], values: ["accept", "decline"], platforms: ["web", "android"] },
+ "call.screen_share": { screen: "CALL", kind: "toggle", actions: ["toggle"], values: ["on", "off"], platforms: ["web", "ios", "android"] },
+ "call.screen_share_respond": { screen: "CALL", kind: "dialog_button", actions: ["tap", "dismiss"], values: ["accept", "decline"], platforms: ["web", "ios", "android"] },
+ "call.captions": { screen: "CALL", kind: "toggle", actions: ["toggle"], values: ["on", "off"], platforms: ["web", "ios", "android"] },
+ "call.caption_language_select": { screen: "CALL", kind: "menu_item", actions: ["tap"], values: null, platforms: ["web", "ios", "android"] },
+ "chat_settings.help_translate_captions": { screen: "CHAT_SETTINGS", kind: "toggle", actions: ["toggle"], values: ["on", "off"], platforms: ["web", "ios", "android"] },
"chat.media_open": { screen: "CHAT", kind: "gesture", actions: ["tap"], values: null, platforms: ["web", "ios", "android"] },
"chat.audio_play": { screen: "CHAT", kind: "toggle", actions: ["toggle"], values: ["play", "pause"], platforms: ["web", "ios", "android"] },
"chat.file_open": { screen: "CHAT", kind: "list_item", actions: ["tap"], values: null, platforms: ["web", "ios", "android"] },
diff --git a/src/lib/call-captions-web.ts b/src/lib/call-captions-web.ts
new file mode 100644
index 0000000..f6f762a
--- /dev/null
+++ b/src/lib/call-captions-web.ts
@@ -0,0 +1,219 @@
+/**
+ * Browser engines for on-device call captions + translation (OneHookBackend/docs/voice-video-calls.md
+ * §12.2 / §12.5, Web row). This is the ONLY place that touches the browser's built-in AI APIs; it
+ * implements the DOM-free {@link CaptionEnginePorts} that `call-captions.ts` orchestrates.
+ *
+ * HARD RULE (§12.2): captions use the Web Speech `SpeechRecognition` recogniser with
+ * `processLocally = true` ONLY — audio never leaves the device, so E2EE holds. This file:
+ * - gates recognition on `SpeechRecognition.available({ langs, processLocally: true })` and offers
+ * `install()` for the language pack;
+ * - constructs every `SpeechRecognition` with `processLocally = true` (there is no code path that
+ * builds one without it, and the no-cloud guard test asserts this);
+ * - uses the built-in `Translator` API (`availability` / `create`) for RECEIVER translation, desktop
+ * Chrome/Edge only, feature-detected — mobile Chrome / Safari / Firefox get a `null` translator
+ * port and fall back to the sender path (or show the original);
+ * - uses the built-in `LanguageDetector` for auto source-language detection (D12).
+ *
+ * No cloud recogniser or cloud translator is ever referenced. Where an engine is missing the port is
+ * `null`, which the session renders as "unavailable", never a remote fallback.
+ */
+
+import type {
+ CaptionEnginePorts,
+ DeviceAvailability,
+ LanguageDetectorPort,
+ RecognitionHandle,
+ RecognitionPort,
+ TranslatorHandle,
+ TranslatorPort,
+} from './call-captions';
+
+// ── Minimal structural typings for the experimental built-in AI APIs (not in lib.dom) ──────────────
+
+type AiAvailability = 'available' | 'downloadable' | 'downloading' | 'unavailable';
+
+interface SpeechRecognitionResultLike {
+ isFinal: boolean;
+ 0: { transcript: string };
+}
+interface SpeechRecognitionEventLike {
+ resultIndex: number;
+ results: { length: number; [index: number]: SpeechRecognitionResultLike };
+}
+interface SpeechRecognitionInstance {
+ lang: string;
+ continuous: boolean;
+ interimResults: boolean;
+ processLocally: boolean;
+ onresult: ((event: SpeechRecognitionEventLike) => void) | null;
+ onerror: ((event: { error?: string }) => void) | null;
+ start(): void;
+ stop(): void;
+ abort(): void;
+}
+interface SpeechRecognitionStatic {
+ new (): SpeechRecognitionInstance;
+ available?(opts: { langs: string[]; processLocally: boolean }): Promise;
+ install?(opts: { langs: string[]; processLocally: boolean }): Promise;
+}
+
+interface TranslatorInstance {
+ translate(text: string): Promise;
+}
+interface TranslatorStatic {
+ availability(opts: { sourceLanguage: string; targetLanguage: string }): Promise;
+ create(opts: {
+ sourceLanguage: string;
+ targetLanguage: string;
+ monitor?: (m: unknown) => void;
+ }): Promise;
+}
+
+interface LanguageDetectorResult {
+ detectedLanguage: string;
+ confidence: number;
+}
+interface LanguageDetectorInstance {
+ detect(text: string): Promise;
+}
+interface LanguageDetectorStatic {
+ availability(): Promise;
+ create(): Promise;
+}
+
+function globalRef(name: string): T | undefined {
+ return (globalThis as unknown as Record)[name];
+}
+
+/** The on-device SpeechRecognition ctor if, and only if, this build exposes the `processLocally` API. */
+function getLocalSpeechRecognition(): SpeechRecognitionStatic | null {
+ const ctor =
+ globalRef('SpeechRecognition') ??
+ globalRef('webkitSpeechRecognition');
+ // Only treat it as usable when the contextual on-device check exists. Without `available`, we
+ // cannot prove `processLocally` is honoured, so we MUST NOT start a (cloud) recogniser (§12.2).
+ if (ctor && typeof ctor.available === 'function') return ctor;
+ return null;
+}
+
+function normalizeAvailability(value: unknown): DeviceAvailability {
+ return value === 'available' || value === 'downloadable' || value === 'downloading'
+ ? value
+ : 'unavailable';
+}
+
+function createRecognitionPort(): RecognitionPort | null {
+ const Ctor = getLocalSpeechRecognition();
+ if (!Ctor) return null;
+
+ return {
+ async availability(lang: string): Promise {
+ try {
+ const result = await Ctor.available!({ langs: [lang], processLocally: true });
+ return normalizeAvailability(result);
+ } catch {
+ return 'unavailable';
+ }
+ },
+ async install(lang: string): Promise {
+ try {
+ if (typeof Ctor.install === 'function') {
+ await Ctor.install({ langs: [lang], processLocally: true });
+ }
+ const result = await Ctor.available!({ langs: [lang], processLocally: true });
+ return normalizeAvailability(result);
+ } catch {
+ return 'unavailable';
+ }
+ },
+ async start(opts): Promise {
+ const recognition = new Ctor();
+ recognition.lang = opts.lang;
+ recognition.continuous = true;
+ recognition.interimResults = true;
+ // HARD RULE: on-device only. Never construct a recogniser without this (§12.2).
+ recognition.processLocally = true;
+ recognition.onresult = (event) => {
+ for (let i = event.resultIndex; i < event.results.length; i += 1) {
+ const result = event.results[i];
+ if (!result) continue;
+ opts.onSegment({ text: result[0]?.transcript ?? '', final: Boolean(result.isFinal) });
+ }
+ };
+ recognition.onerror = (event) => opts.onError(new Error(event.error ?? 'recognition-error'));
+ recognition.start();
+ return {
+ stop() {
+ try {
+ recognition.stop();
+ } catch {
+ /* already stopped */
+ }
+ },
+ };
+ },
+ };
+}
+
+function createTranslatorPort(): TranslatorPort | null {
+ const Translator = globalRef('Translator');
+ if (!Translator || typeof Translator.availability !== 'function') return null;
+
+ return {
+ async availability(source: string, target: string): Promise {
+ try {
+ const result = await Translator.availability({ sourceLanguage: source, targetLanguage: target });
+ return normalizeAvailability(result);
+ } catch {
+ return 'unavailable';
+ }
+ },
+ async create(source: string, target: string): Promise {
+ const translator = await Translator.create({ sourceLanguage: source, targetLanguage: target });
+ return { translate: (text: string) => translator.translate(text) };
+ },
+ };
+}
+
+function createDetectorPort(): LanguageDetectorPort | null {
+ const Detector = globalRef('LanguageDetector');
+ if (!Detector || typeof Detector.availability !== 'function') return null;
+
+ let instance: LanguageDetectorInstance | null = null;
+ return {
+ async availability(): Promise {
+ try {
+ return normalizeAvailability(await Detector.availability());
+ } catch {
+ return 'unavailable';
+ }
+ },
+ async detect(text: string): Promise {
+ try {
+ if (!instance) instance = await Detector.create();
+ const results = await instance.detect(text);
+ return results[0]?.detectedLanguage ?? null;
+ } catch {
+ return null;
+ }
+ },
+ };
+}
+
+/**
+ * Feature-detects the browser's on-device caption engines and returns the ports. Any engine that is
+ * absent (or whose on-device guarantee can't be proven) is `null`, so the session treats it as
+ * unavailable rather than ever reaching a cloud service.
+ */
+export function createWebCaptionPorts(): CaptionEnginePorts {
+ return {
+ recognition: createRecognitionPort(),
+ translator: createTranslatorPort(),
+ detector: createDetectorPort(),
+ };
+}
+
+/** Quick check for whether captions can be offered at all on this browser (drives the toggle). */
+export function webCaptionsSupported(): boolean {
+ return getLocalSpeechRecognition() != null;
+}
diff --git a/src/lib/call-captions.ts b/src/lib/call-captions.ts
new file mode 100644
index 0000000..717fbd4
--- /dev/null
+++ b/src/lib/call-captions.ts
@@ -0,0 +1,845 @@
+/**
+ * On-device live captions + translation for 1:1 calls — the PURE protocol, engine-selection and
+ * orchestration layer of OneHookBackend/docs/voice-video-calls.md §12.
+ *
+ * This module is deliberately free of DOM, WebRTC and network dependencies. Everything that touches a
+ * browser API (the Web Speech `SpeechRecognition` recogniser, the built-in `Translator` and
+ * `LanguageDetector`) is reached ONLY through the injected {@link CaptionEnginePorts}, so the whole
+ * machine is unit-testable under Node and the concrete web engines live in `call-captions-web.ts`.
+ *
+ * HARD RULES this module encodes (§12.2 / §12.5, and decisions D9, D12, D13):
+ * - **On-device only, NEVER a cloud recogniser or cloud translator.** {@link selectRecognitionEngine}
+ * and {@link selectTranslationStrategy} have no "cloud" branch by construction: when the device
+ * cannot run recognition/translation locally the result is `unavailable`, never a remote fallback.
+ * The ports are documented to feature-detect the LOCAL engine only.
+ * - **Ephemeral (D9).** Captions live only in {@link CaptionUiState.lines}, in memory, for the life
+ * of the call. Nothing here persists, logs or transmits caption text anywhere except the E2EE
+ * `control` data channel to the one peer (via the injected {@link CaptionSessionConfig.send}).
+ * - **Auto-detected source language (D12).** The speaker's language is detected on final segments
+ * via the detector port; only if detection is unavailable do we fall back to the device language.
+ * The viewer's target language defaults to the device locale and is changeable in-call.
+ * - **Sender-side fallback is opt-in (D13).** We only translate our OWN captions for a peer when the
+ * local user has turned on "Help translate my captions" (`helpTranslateCaptions`).
+ *
+ * The on-wire shape is the §12.3 `control`-channel format. Unknown fields/types are ignored, matching
+ * the attachment/call-signal envelopes — do not rename a field without changing the doc and the other
+ * clients.
+ */
+
+/** The only caption-protocol version this build produces and reads. */
+export const CAPTION_PROTOCOL_VERSION = 1;
+
+/** Partials are throttled to at most 4/s (§12.3). */
+export const CAPTION_PARTIAL_MIN_INTERVAL_MS = 250;
+
+/** How many caption lines are retained in memory for the scrollable panel (ephemeral, D9). */
+export const CAPTION_MAX_LINES = 200;
+
+// ── Wire format (§12.3) ──────────────────────────────────────────────────────────────────────────
+
+/** The caption-family message types that travel over the `control` data channel. */
+export type CaptionWireType =
+ | 'caption'
+ | 'caption.state'
+ | 'translate.request'
+ | 'translate.response'
+ | 'capabilities';
+
+const CAPTION_WIRE_TYPES: readonly CaptionWireType[] = [
+ 'caption',
+ 'caption.state',
+ 'translate.request',
+ 'translate.response',
+ 'capabilities',
+];
+
+/** A single translation carried alongside a caption (sender-side fallback, §12.5). */
+export interface CaptionTranslation {
+ lang: string;
+ text: string;
+ /** Always `device` — a cloud engine is never used, so this is the only value we ever emit. */
+ engine: string;
+}
+
+/** `caption`: one partial (replaces the line with the same `seq`) or final caption segment. */
+export interface CaptionMessage {
+ type: 'caption';
+ v: number;
+ seq: number;
+ lang: string;
+ final: boolean;
+ text: string;
+ tStartMs?: number;
+ tr?: CaptionTranslation;
+}
+
+/** `caption.state`: "Captions on" indicator (`enabled`/`lang`) and the viewer's `wantLang`. */
+export interface CaptionStateMessage {
+ type: 'caption.state';
+ v: number;
+ enabled?: boolean;
+ lang?: string | null;
+ wantLang?: string | null;
+}
+
+/** `translate.request` (viewer → speaker): "please translate your captions to `targetLang`". */
+export interface TranslateRequestMessage {
+ type: 'translate.request';
+ v: number;
+ targetLang: string;
+}
+
+/** `translate.response` (speaker → viewer): whether the sender fallback will run. */
+export interface TranslateResponseMessage {
+ type: 'translate.response';
+ v: number;
+ ok: boolean;
+ reason?: string;
+}
+
+/** `capabilities`: the language pairs this device can translate on-device (may be empty / unknown). */
+export interface CaptionCapabilitiesMessage {
+ type: 'capabilities';
+ v: number;
+ translatePairs: TranslatePair[];
+}
+
+/** One translatable language pair advertised in `capabilities.translatePairs`. */
+export interface TranslatePair {
+ source: string;
+ target: string;
+}
+
+export type CaptionWireMessage =
+ | CaptionMessage
+ | CaptionStateMessage
+ | TranslateRequestMessage
+ | TranslateResponseMessage
+ | CaptionCapabilitiesMessage;
+
+// ── Builders (always stamp the current protocol version) ───────────────────────────────────────────
+
+export function buildCaption(input: Omit): CaptionMessage {
+ return { type: 'caption', v: CAPTION_PROTOCOL_VERSION, ...input };
+}
+
+export function buildCaptionState(input: Omit): CaptionStateMessage {
+ return { type: 'caption.state', v: CAPTION_PROTOCOL_VERSION, ...input };
+}
+
+export function buildTranslateRequest(targetLang: string): TranslateRequestMessage {
+ return { type: 'translate.request', v: CAPTION_PROTOCOL_VERSION, targetLang };
+}
+
+export function buildTranslateResponse(ok: boolean, reason?: string): TranslateResponseMessage {
+ const message: TranslateResponseMessage = { type: 'translate.response', v: CAPTION_PROTOCOL_VERSION, ok };
+ if (reason) message.reason = reason;
+ return message;
+}
+
+export function buildCapabilities(translatePairs: TranslatePair[]): CaptionCapabilitiesMessage {
+ return { type: 'capabilities', v: CAPTION_PROTOCOL_VERSION, translatePairs };
+}
+
+/** True when a `control` message type belongs to the caption family (vs screen-share / other). */
+export function isCaptionWireType(type: unknown): type is CaptionWireType {
+ return typeof type === 'string' && (CAPTION_WIRE_TYPES as readonly string[]).includes(type);
+}
+
+/**
+ * Parses + validates a caption-family `control` message. Accepts either a JSON string or an already
+ * parsed object (the control channel delivers strings; tests pass objects). Returns `null` — never
+ * throws — for anything that is not a valid caption message for this protocol version, so a junk or
+ * future-version frame is dropped quietly. Only known fields are copied, so an unknown future field
+ * is never trusted or echoed.
+ */
+export function parseCaptionWire(raw: unknown): CaptionWireMessage | null {
+ let value: unknown = raw;
+ if (typeof raw === 'string') {
+ try {
+ value = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ }
+ if (value === null || typeof value !== 'object') return null;
+ const record = value as Record;
+ if (!isCaptionWireType(record.type)) return null;
+ if (record.v !== CAPTION_PROTOCOL_VERSION) return null;
+
+ switch (record.type) {
+ case 'caption': {
+ if (typeof record.seq !== 'number') return null;
+ if (typeof record.lang !== 'string') return null;
+ if (typeof record.final !== 'boolean') return null;
+ if (typeof record.text !== 'string') return null;
+ const message: CaptionMessage = {
+ type: 'caption',
+ v: CAPTION_PROTOCOL_VERSION,
+ seq: record.seq,
+ lang: record.lang,
+ final: record.final,
+ text: record.text,
+ };
+ if (typeof record.tStartMs === 'number') message.tStartMs = record.tStartMs;
+ const tr = record.tr;
+ if (tr !== null && typeof tr === 'object') {
+ const trRecord = tr as Record;
+ if (typeof trRecord.lang === 'string' && typeof trRecord.text === 'string') {
+ message.tr = {
+ lang: trRecord.lang,
+ text: trRecord.text,
+ engine: typeof trRecord.engine === 'string' ? trRecord.engine : 'device',
+ };
+ }
+ }
+ return message;
+ }
+ case 'caption.state': {
+ const message: CaptionStateMessage = { type: 'caption.state', v: CAPTION_PROTOCOL_VERSION };
+ if (typeof record.enabled === 'boolean') message.enabled = record.enabled;
+ if (typeof record.lang === 'string' || record.lang === null) message.lang = record.lang as string | null;
+ if (typeof record.wantLang === 'string' || record.wantLang === null) {
+ message.wantLang = record.wantLang as string | null;
+ }
+ return message;
+ }
+ case 'translate.request': {
+ if (typeof record.targetLang !== 'string' || record.targetLang.length === 0) return null;
+ return { type: 'translate.request', v: CAPTION_PROTOCOL_VERSION, targetLang: record.targetLang };
+ }
+ case 'translate.response': {
+ if (typeof record.ok !== 'boolean') return null;
+ const message: TranslateResponseMessage = { type: 'translate.response', v: CAPTION_PROTOCOL_VERSION, ok: record.ok };
+ if (typeof record.reason === 'string') message.reason = record.reason;
+ return message;
+ }
+ case 'capabilities': {
+ const pairs: TranslatePair[] = Array.isArray(record.translatePairs)
+ ? record.translatePairs
+ .filter((p): p is Record => p !== null && typeof p === 'object')
+ .filter((p) => typeof p.source === 'string' && typeof p.target === 'string')
+ .map((p) => ({ source: p.source as string, target: p.target as string }))
+ : [];
+ return { type: 'capabilities', v: CAPTION_PROTOCOL_VERSION, translatePairs: pairs };
+ }
+ default:
+ return null;
+ }
+}
+
+// ── Language helpers (§12 / D12) ────────────────────────────────────────────────────────────────────
+
+/** The base (ISO 639) language subtag, lowercased: `en-IN` → `en`, `zh-Hant-TW` → `zh`. */
+export function baseLanguage(code: string | null | undefined): string {
+ return (code ?? '').toLowerCase().split('-')[0];
+}
+
+/** Two BCP-47 codes are the "same language" when their base subtags match (no translation needed). */
+export function isSameLanguage(a: string | null | undefined, b: string | null | undefined): boolean {
+ const left = baseLanguage(a);
+ const right = baseLanguage(b);
+ return left.length > 0 && left === right;
+}
+
+/**
+ * The speaker's spoken language (D12): auto-detected when the detector produced a result, otherwise
+ * the device language. The user is NEVER asked to pick their own spoken language unless detection is
+ * unavailable (in which case the caller supplies `fallback = navigator.language`).
+ */
+export function pickSourceLanguage(detected: string | null | undefined, fallback: string): string {
+ return detected && detected.length > 0 ? detected : fallback;
+}
+
+/** The viewer's target language (D12): their in-call override if set, else the device locale. */
+export function pickTargetLanguage(deviceLocale: string, override?: string | null): string {
+ return override && override.length > 0 ? override : deviceLocale;
+}
+
+// ── Engine selection (on-device only — NO cloud branch exists) ──────────────────────────────────────
+
+/** Mirrors the availability states the built-in web engines report (`available()` / `availability()`). */
+export type DeviceAvailability = 'available' | 'downloadable' | 'downloading' | 'unavailable';
+
+/**
+ * The recognition decision. Note every non-`unavailable` branch carries `processLocally: true`: this
+ * type makes it impossible to represent a cloud recogniser. If the device cannot recognise locally,
+ * the only outcome is `unavailable` with a reason to show on the disabled toggle (§12.2 Hard rule).
+ */
+export type RecognitionDecision =
+ | { status: 'ready'; processLocally: true }
+ | { status: 'needs-install'; processLocally: true; reason: string }
+ | { status: 'installing'; processLocally: true; reason: string }
+ | { status: 'unavailable'; reason: string };
+
+/**
+ * Decides whether captions can be offered for `lang`, from the on-device recogniser's availability.
+ * `supported` is the feature-detection result (web: `SpeechRecognition` + the `processLocally` option
+ * exist). The cloud path is never selected — an unsupported device yields `unavailable`.
+ */
+export function selectRecognitionEngine(input: {
+ supported: boolean;
+ availability: DeviceAvailability;
+}): RecognitionDecision {
+ if (!input.supported) {
+ return { status: 'unavailable', reason: 'Not supported on this device' };
+ }
+ switch (input.availability) {
+ case 'available':
+ return { status: 'ready', processLocally: true };
+ case 'downloadable':
+ return { status: 'needs-install', processLocally: true, reason: 'Download the language pack' };
+ case 'downloading':
+ return { status: 'installing', processLocally: true, reason: 'Downloading the language pack…' };
+ case 'unavailable':
+ default:
+ return { status: 'unavailable', reason: 'Not supported on this device' };
+ }
+}
+
+/** Who (if anyone) translates a caption, per the §12.5 receiver-first model. Never a cloud service. */
+export type TranslationMode = 'none' | 'receiver' | 'sender' | 'unavailable';
+
+export interface TranslationDecision {
+ mode: TranslationMode;
+ /** For `receiver`: the on-device model still needs a download before it can translate. */
+ needsInstall?: boolean;
+ /** For `unavailable`: user-facing reason. */
+ reason?: string;
+}
+
+/**
+ * Decides who translates the other person's captions into the viewer's language (§12.5):
+ * - `none` — no target, or the caption is already in the target language;
+ * - `receiver` — this (the viewing) device can translate the pair on-device (tried FIRST);
+ * - `sender` — the receiver cannot, but the speaker opted into "Help translate my captions"
+ * (`senderHelpEnabled`) and advertised the pair (`senderCanTranslate`);
+ * - `unavailable` — neither device can; the viewer sees the original with a note.
+ *
+ * There is deliberately NO cloud branch: a device that cannot translate on-device falls back only to
+ * the OTHER device's on-device engine, and if that also can't, to showing the original.
+ */
+export function selectTranslationStrategy(input: {
+ sourceLang: string;
+ targetLang: string | null;
+ translatorSupported: boolean;
+ receiverAvailability: DeviceAvailability;
+ senderHelpEnabled: boolean;
+ senderCanTranslate: boolean;
+}): TranslationDecision {
+ if (!input.targetLang || isSameLanguage(input.sourceLang, input.targetLang)) {
+ return { mode: 'none' };
+ }
+ // Receiver always tries first (§12.5).
+ if (input.translatorSupported) {
+ if (input.receiverAvailability === 'available') return { mode: 'receiver' };
+ if (input.receiverAvailability === 'downloadable' || input.receiverAvailability === 'downloading') {
+ return { mode: 'receiver', needsInstall: true };
+ }
+ }
+ // Receiver cannot → sender fallback, but only if the speaker opted in AND can do the pair (D13).
+ if (input.senderHelpEnabled && input.senderCanTranslate) {
+ return { mode: 'sender' };
+ }
+ return { mode: 'unavailable', reason: 'Translation unavailable' };
+}
+
+// ── Orchestration ports (the only seam onto the browser engines) ────────────────────────────────────
+
+export interface RecognitionHandle {
+ stop(): void;
+}
+
+/**
+ * On-device speech recognition of the LOCAL microphone. The concrete web implementation MUST gate on
+ * `SpeechRecognition.available({ langs, processLocally: true })` and MUST construct the recogniser
+ * with `processLocally = true` — never a cloud recogniser (§12.2 Hard rule).
+ */
+export interface RecognitionPort {
+ availability(lang: string): Promise;
+ /** Trigger the on-device language-pack download; resolves with the resulting availability. */
+ install(lang: string): Promise;
+ start(opts: {
+ lang: string;
+ onSegment(segment: { text: string; final: boolean }): void;
+ onError(error: Error): void;
+ }): Promise;
+}
+
+export interface TranslatorHandle {
+ translate(text: string): Promise;
+}
+
+/** On-device translation (built-in `Translator` API). Never a cloud translator (§12.5 Hard rule). */
+export interface TranslatorPort {
+ availability(source: string, target: string): Promise;
+ create(source: string, target: string): Promise;
+}
+
+/** On-device language detection (built-in `LanguageDetector`). Returns the top code, or `null`. */
+export interface LanguageDetectorPort {
+ availability(): Promise;
+ detect(text: string): Promise;
+}
+
+export interface CaptionEnginePorts {
+ /** `null` ⇒ captions are not supported on this device; the toggle is disabled with a reason. */
+ recognition: RecognitionPort | null;
+ /** `null` ⇒ no on-device RECEIVER translation here; a sender fallback may still apply. */
+ translator: TranslatorPort | null;
+ /** `null` ⇒ no on-device detection; source language falls back to the device language (D12). */
+ detector: LanguageDetectorPort | null;
+}
+
+// ── In-memory caption state (ephemeral — D9) ────────────────────────────────────────────────────────
+
+export type CaptionTranslationState = 'none' | 'pending' | 'done' | 'unavailable';
+
+export interface CaptionLine {
+ seq: number;
+ /** `local` = this user's own speech; `remote` = the other person's. */
+ source: 'local' | 'remote';
+ lang: string;
+ text: string;
+ final: boolean;
+ tStartMs?: number;
+ /** The translation into the viewer's target language, when available. */
+ translatedText?: string;
+ translationState: CaptionTranslationState;
+}
+
+export interface CaptionUiState {
+ /** Whether this device is captioning its own mic. */
+ localEnabled: boolean;
+ /** Whether on-device recognition is usable here at all (drives the disabled toggle + reason). */
+ localAvailable: boolean;
+ localUnavailableReason?: string;
+ /** A language-pack download is required before local captions can start. */
+ localNeedsInstall: boolean;
+ /** Whether the other person is captioning (either side captioning shows a visible indicator). */
+ peerCaptioning: boolean;
+ /** The viewer's chosen target language (defaults to the device locale; changeable in-call — D12). */
+ targetLang: string;
+ /** Live caption lines, newest last. In memory only; dropped when the call ends (D9). */
+ lines: CaptionLine[];
+}
+
+// ── Orchestrator ──────────────────────────────────────────────────────────────────────────────────
+
+export interface CaptionSessionConfig {
+ ports: CaptionEnginePorts;
+ /** Sends a caption-family message over the E2EE `control` channel to the one peer. */
+ send(message: CaptionWireMessage): void;
+ /** Receives every immutable snapshot of {@link CaptionUiState} for the UI to render. */
+ onChange(state: CaptionUiState): void;
+ /** Device language, used as the detection fallback AND the default target (D12). */
+ deviceLanguage: string;
+ /** D13: the local user's "Help translate my captions" setting (enables the sender fallback). */
+ helpTranslateCaptions: boolean;
+ /** Override the default target language (otherwise the device locale). */
+ initialTargetLang?: string;
+ maxLines?: number;
+}
+
+/**
+ * Drives on-device captions + translation for ONE call, over the `control` channel. Transcribes only
+ * the LOCAL mic (§12.1), auto-detects the spoken language (D12), translates the peer's captions
+ * receiver-first with a sender fallback (§12.5), and keeps everything in memory only (D9).
+ *
+ * DOM-free: all engine access is through {@link CaptionEnginePorts}, so this is unit-tested with fakes
+ * and never reaches a cloud service — the fallback for "can't translate" is the original text, never a
+ * remote call.
+ */
+export class CaptionSession {
+ private readonly cfg: CaptionSessionConfig;
+ private readonly maxLines: number;
+
+ private localEnabled = false;
+ private localAvailable = false;
+ private localUnavailableReason: string | undefined;
+ private localNeedsInstall = false;
+ private peerCaptioning = false;
+ private targetLang: string;
+
+ private readonly lines: CaptionLine[] = [];
+ private readonly lineIndex = new Map();
+
+ private recognition: RecognitionHandle | null = null;
+ private localSeq = 0;
+ private localLang: string;
+ private lastPartialSentAt = 0;
+
+ /** The viewer's target language the PEER asked us to translate our captions into (sender fallback). */
+ private peerWantLang: string | null = null;
+ /** True once we agreed to run the sender fallback for the peer (D13 gate already passed). */
+ private senderFallbackActive = false;
+ private senderTranslator: TranslatorHandle | null = null;
+ private readonly receiverTranslators = new Map();
+ private requestedSenderFor: string | null = null;
+ private disposed = false;
+
+ constructor(config: CaptionSessionConfig) {
+ this.cfg = config;
+ this.maxLines = config.maxLines ?? CAPTION_MAX_LINES;
+ this.targetLang = pickTargetLanguage(config.deviceLanguage, config.initialTargetLang);
+ this.localLang = config.deviceLanguage;
+ }
+
+ getState(): CaptionUiState {
+ return {
+ localEnabled: this.localEnabled,
+ localAvailable: this.localAvailable,
+ localUnavailableReason: this.localUnavailableReason,
+ localNeedsInstall: this.localNeedsInstall,
+ peerCaptioning: this.peerCaptioning,
+ targetLang: this.targetLang,
+ lines: [...this.lines],
+ };
+ }
+
+ /** Probe on-device recognition availability so the UI can enable or disable the toggle (§12.2). */
+ async refreshAvailability(): Promise {
+ const port = this.cfg.ports.recognition;
+ if (!port) {
+ this.localAvailable = false;
+ this.localNeedsInstall = false;
+ this.localUnavailableReason = 'Not supported on this device';
+ this.emit();
+ return;
+ }
+ const availability = await port.availability(this.cfg.deviceLanguage);
+ const decision = selectRecognitionEngine({ supported: true, availability });
+ this.applyRecognitionDecision(decision);
+ this.emit();
+ }
+
+ private applyRecognitionDecision(decision: RecognitionDecision): void {
+ if (decision.status === 'unavailable') {
+ this.localAvailable = false;
+ this.localNeedsInstall = false;
+ this.localUnavailableReason = decision.reason;
+ return;
+ }
+ // ready / needs-install / installing are all on-device (processLocally: true by the type).
+ this.localAvailable = decision.status === 'ready';
+ this.localNeedsInstall = decision.status !== 'ready';
+ this.localUnavailableReason = decision.status === 'ready' ? undefined : decision.reason;
+ }
+
+ /** Change the viewer's target language in-call (D12); re-evaluates every remote line. */
+ async setTargetLanguage(lang: string): Promise {
+ this.targetLang = lang;
+ this.peerWantLang = null; // our own want; sent to the peer so their fallback can match us
+ this.requestedSenderFor = null;
+ this.senderTranslator = null;
+ this.cfg.send(buildCaptionState({ wantLang: lang }));
+ for (const line of this.lines) {
+ if (line.source === 'remote' && line.final) await this.translateRemoteLine(line);
+ }
+ this.emit();
+ }
+
+ getTargetLanguage(): string {
+ return this.targetLang;
+ }
+
+ /** Start captioning the local mic (§12.1). Installs the language pack first if needed. */
+ async enableLocalCaptions(): Promise {
+ const port = this.cfg.ports.recognition;
+ if (!port || this.localEnabled) return;
+ let availability = await port.availability(this.cfg.deviceLanguage);
+ let decision = selectRecognitionEngine({ supported: true, availability });
+ if (decision.status === 'needs-install') {
+ availability = await port.install(this.cfg.deviceLanguage);
+ decision = selectRecognitionEngine({ supported: true, availability });
+ }
+ this.applyRecognitionDecision(decision);
+ if (decision.status !== 'ready') {
+ this.emit();
+ return;
+ }
+ this.localLang = this.cfg.deviceLanguage;
+ this.recognition = await port.start({
+ lang: this.cfg.deviceLanguage,
+ onSegment: (segment) => void this.onLocalSegment(segment),
+ onError: () => this.disableLocalCaptions(),
+ });
+ this.localEnabled = true;
+ this.cfg.send(buildCaptionState({ enabled: true, lang: this.localLang }));
+ this.emit();
+ }
+
+ /** Stop captioning the local mic and tell the peer. */
+ disableLocalCaptions(): void {
+ if (!this.localEnabled && !this.recognition) return;
+ this.recognition?.stop();
+ this.recognition = null;
+ this.localEnabled = false;
+ this.cfg.send(buildCaptionState({ enabled: false }));
+ this.emit();
+ }
+
+ private async onLocalSegment(segment: { text: string; final: boolean }): Promise {
+ if (this.disposed) return;
+ const now = Date.now();
+ if (!segment.final) {
+ // Partials replace the current line; throttle to ≤4/s (§12.3).
+ if (now - this.lastPartialSentAt < CAPTION_PARTIAL_MIN_INTERVAL_MS) return;
+ this.lastPartialSentAt = now;
+ const message = buildCaption({ seq: this.localSeq, lang: this.localLang, final: false, text: segment.text });
+ this.upsertLine('local', message);
+ this.cfg.send(message);
+ this.emit();
+ return;
+ }
+
+ // Final: auto-detect the spoken language (D12), then commit and send.
+ this.localLang = await this.detectLanguage(segment.text);
+ const message = buildCaption({ seq: this.localSeq, lang: this.localLang, final: true, text: segment.text });
+ if (this.senderFallbackActive && this.peerWantLang) {
+ const tr = await this.senderTranslate(segment.text, this.localLang, this.peerWantLang);
+ if (tr) message.tr = tr;
+ }
+ this.upsertLine('local', message);
+ this.cfg.send(message);
+ this.localSeq += 1;
+ this.lastPartialSentAt = 0;
+ this.emit();
+ }
+
+ private async detectLanguage(text: string): Promise {
+ const detector = this.cfg.ports.detector;
+ if (!detector) return pickSourceLanguage(null, this.cfg.deviceLanguage);
+ try {
+ const availability = await detector.availability();
+ if (availability !== 'available') return pickSourceLanguage(null, this.cfg.deviceLanguage);
+ const detected = await detector.detect(text);
+ return pickSourceLanguage(detected, this.cfg.deviceLanguage);
+ } catch {
+ return pickSourceLanguage(null, this.cfg.deviceLanguage);
+ }
+ }
+
+ /** Route an incoming caption-family `control` message (already parsed by {@link parseCaptionWire}). */
+ async handleWire(message: CaptionWireMessage): Promise {
+ if (this.disposed) return;
+ switch (message.type) {
+ case 'caption':
+ await this.onRemoteCaption(message);
+ break;
+ case 'caption.state':
+ this.onCaptionState(message);
+ break;
+ case 'translate.request':
+ await this.onTranslateRequest(message);
+ break;
+ case 'translate.response':
+ this.onTranslateResponse(message);
+ break;
+ case 'capabilities':
+ // translatePairs is advisory; receiver-first still probes locally and negotiates via
+ // translate.request, so nothing to do beyond ignoring unknown pairs here.
+ break;
+ default:
+ break;
+ }
+ }
+
+ private onCaptionState(message: CaptionStateMessage): void {
+ if (typeof message.enabled === 'boolean') this.peerCaptioning = message.enabled;
+ if (message.wantLang !== undefined) this.peerWantLang = message.wantLang;
+ this.emit();
+ }
+
+ private async onRemoteCaption(message: CaptionMessage): Promise {
+ const line = this.upsertLine('remote', message);
+ this.peerCaptioning = true;
+ if (message.final) {
+ await this.translateRemoteLine(line);
+ } else {
+ line.translationState = isSameLanguage(message.lang, this.targetLang) || !this.targetLang ? 'none' : 'pending';
+ }
+ this.emit();
+ }
+
+ /**
+ * Translate ONE final remote line into the target language, receiver-first then sender fallback
+ * (§12.5). `upsertLine` has already consumed any `tr` the sender attached, so a line that is
+ * already `done` here was translated by the sender and needs nothing further.
+ */
+ private async translateRemoteLine(line: CaptionLine): Promise {
+ if (!line.final) return;
+ if (line.translationState === 'done' && line.translatedText != null) return;
+
+ const decision = selectTranslationStrategy({
+ sourceLang: line.lang,
+ targetLang: this.targetLang,
+ translatorSupported: this.cfg.ports.translator != null,
+ receiverAvailability: await this.receiverAvailability(line.lang, this.targetLang),
+ // Chrome publishes no per-language list and the peer's ability is only known from a
+ // translate.response, so we optimistically consider the sender path and let the response (or a
+ // sender-attached `tr`) confirm it. onTranslateResponse flips pending → unavailable on a refusal.
+ senderHelpEnabled: true,
+ senderCanTranslate: true,
+ });
+
+ if (decision.mode === 'none') {
+ line.translatedText = undefined;
+ line.translationState = 'none';
+ return;
+ }
+ if (decision.mode === 'receiver') {
+ const translated = await this.receiverTranslate(line.text, line.lang, this.targetLang);
+ if (translated != null) {
+ line.translatedText = translated;
+ line.translationState = 'done';
+ return;
+ }
+ // The receiver model unexpectedly failed → ask the sender to help instead.
+ }
+ if (decision.mode === 'sender' || decision.mode === 'receiver') {
+ this.requestSenderFallback(this.targetLang);
+ line.translationState = 'pending';
+ return;
+ }
+ line.translatedText = undefined;
+ line.translationState = 'unavailable';
+ }
+
+ private async receiverAvailability(source: string, target: string): Promise {
+ const port = this.cfg.ports.translator;
+ if (!port) return 'unavailable';
+ try {
+ return await port.availability(baseLanguage(source), baseLanguage(target));
+ } catch {
+ return 'unavailable';
+ }
+ }
+
+ private async receiverTranslate(text: string, source: string, target: string): Promise {
+ const port = this.cfg.ports.translator;
+ if (!port) return null;
+ const key = `${baseLanguage(source)}>${baseLanguage(target)}`;
+ try {
+ let translator = this.receiverTranslators.get(key);
+ if (!translator) {
+ translator = await port.create(baseLanguage(source), baseLanguage(target));
+ this.receiverTranslators.set(key, translator);
+ }
+ return await translator.translate(text);
+ } catch {
+ return null;
+ }
+ }
+
+ private requestSenderFallback(targetLang: string): void {
+ if (this.requestedSenderFor === targetLang) return;
+ this.requestedSenderFor = targetLang;
+ this.cfg.send(buildCaptionState({ wantLang: targetLang }));
+ this.cfg.send(buildTranslateRequest(targetLang));
+ }
+
+ /** The peer asked US to translate our own captions for them (sender fallback, D13-gated). */
+ private async onTranslateRequest(message: TranslateRequestMessage): Promise {
+ this.peerWantLang = message.targetLang;
+ if (!this.cfg.helpTranslateCaptions) {
+ this.cfg.send(buildTranslateResponse(false, 'help-disabled'));
+ this.senderFallbackActive = false;
+ return;
+ }
+ const port = this.cfg.ports.translator;
+ if (!port) {
+ this.cfg.send(buildTranslateResponse(false, 'unsupported'));
+ this.senderFallbackActive = false;
+ return;
+ }
+ const availability = await this.receiverAvailability(this.localLang, message.targetLang);
+ if (availability === 'unavailable') {
+ this.cfg.send(buildTranslateResponse(false, 'pair-unsupported'));
+ this.senderFallbackActive = false;
+ return;
+ }
+ this.senderFallbackActive = true;
+ this.senderTranslator = null; // created lazily per final segment
+ this.cfg.send(buildTranslateResponse(true));
+ }
+
+ private onTranslateResponse(message: TranslateResponseMessage): void {
+ if (message.ok) return; // the sender will attach `tr` to future finals; nothing to flip here.
+ // Neither device can translate the pair → mark pending remote lines unavailable (§12.5 note).
+ for (const line of this.lines) {
+ if (line.source === 'remote' && line.translationState === 'pending') {
+ line.translationState = 'unavailable';
+ }
+ }
+ this.emit();
+ }
+
+ private async senderTranslate(
+ text: string,
+ source: string,
+ target: string
+ ): Promise {
+ const port = this.cfg.ports.translator;
+ if (!port) return null;
+ try {
+ if (!this.senderTranslator) {
+ this.senderTranslator = await port.create(baseLanguage(source), baseLanguage(target));
+ }
+ const translated = await this.senderTranslator.translate(text);
+ return { lang: target, text: translated, engine: 'device' };
+ } catch {
+ return null;
+ }
+ }
+
+ /** Insert or replace a caption line keyed by (source, seq). Partials replace; finals commit. */
+ private upsertLine(source: 'local' | 'remote', message: CaptionMessage): CaptionLine {
+ const key = `${source}:${message.seq}`;
+ let line = this.lineIndex.get(key);
+ if (!line) {
+ line = {
+ seq: message.seq,
+ source,
+ lang: message.lang,
+ text: message.text,
+ final: message.final,
+ tStartMs: message.tStartMs,
+ translationState: 'none',
+ };
+ this.lineIndex.set(key, line);
+ this.lines.push(line);
+ if (this.lines.length > this.maxLines) {
+ const removed = this.lines.shift();
+ if (removed) this.lineIndex.delete(`${removed.source}:${removed.seq}`);
+ }
+ } else {
+ line.lang = message.lang;
+ line.text = message.text;
+ line.final = message.final;
+ }
+ // A caption that carried a sender-side translation into our target language is immediately usable.
+ if (message.tr && isSameLanguage(message.tr.lang, this.targetLang)) {
+ line.translatedText = message.tr.text;
+ line.translationState = 'done';
+ }
+ return line;
+ }
+
+ private emit(): void {
+ if (this.disposed) return;
+ this.cfg.onChange(this.getState());
+ }
+
+ /** Stop recognition and drop all in-memory captions (D9). Idempotent. */
+ dispose(): void {
+ if (this.disposed) return;
+ this.disposed = true;
+ this.recognition?.stop();
+ this.recognition = null;
+ this.lines.length = 0;
+ this.lineIndex.clear();
+ this.receiverTranslators.clear();
+ this.senderTranslator = null;
+ }
+}
diff --git a/src/lib/call-controller.ts b/src/lib/call-controller.ts
new file mode 100644
index 0000000..0094103
--- /dev/null
+++ b/src/lib/call-controller.ts
@@ -0,0 +1,417 @@
+/**
+ * GlobalCallController — the ONE, app-wide voice/video call orchestrator
+ * (OneHookBackend/docs/voice-video-calls.md). It owns the single `onCallEvent(userId)` subscription
+ * and the single live {@link CallSession}, so a call rings on ANY screen, not only while a particular
+ * conversation is open (§10, web). Conversation-level call buttons drive it through {@link startCall}.
+ *
+ * It is framework-agnostic — a plain class with a snapshot + listener surface (like `CallSession`,
+ * every dependency is injected) — so it is unit-testable under Node with fakes, and the React
+ * `CallProvider` is a thin wrapper over it. Key differences from the old per-conversation hook:
+ * - INCOMING events are NOT filtered by an "open conversation": the modal shows for every match;
+ * - the caller/callee peer identity for an incoming call is resolved lazily via {@link resolvePeer};
+ * - it wires the §12 on-device {@link CaptionSession} onto the call's `control` channel.
+ *
+ * No SDP / ICE / caption text is ever logged here; captions stay in memory and never reach the
+ * backend (§9.1 / D9).
+ */
+
+import type {
+ Call,
+ CallEndReason,
+ CallEvent,
+ CallMedia,
+} from '../api/calls';
+import {
+ CallSession,
+ type CallRole,
+ type CallSessionHandlers,
+ type CallSessionState,
+ type ScreenShareState,
+} from './call-session';
+import {
+ CaptionSession,
+ type CaptionEnginePorts,
+ type CaptionUiState,
+ parseCaptionWire,
+} from './call-captions';
+
+/** A pending incoming call shown by the ring modal, with its peer already resolved for the UI. */
+export interface IncomingCall {
+ callId: string;
+ matchId: string;
+ media: CallMedia;
+ callerDeviceId?: string;
+ peerId: string;
+ peerName: string;
+}
+
+/** What the caller supplies when starting a call from a conversation. */
+export interface StartCallArgs {
+ matchId: string;
+ peerId: string;
+ peerName: string;
+ media: CallMedia;
+}
+
+/** Immutable snapshot the UI renders. */
+export interface GlobalCallSnapshot {
+ active: boolean;
+ state: CallSessionState;
+ media: CallMedia;
+ activeMatchId: string | null;
+ peerName: string;
+ incoming: IncomingCall | null;
+ localStream: MediaStream | null;
+ remoteStream: MediaStream | null;
+ screenStream: MediaStream | null;
+ sas: string[] | null;
+ muted: boolean;
+ cameraOff: boolean;
+ screenShareState: ScreenShareState;
+ screenRequest: string | null;
+ canSendScreen: boolean;
+ lastEndReason: CallEndReason | null;
+ error: string | null;
+ /** Bumps whenever a call ends, so a conversation can refresh its call-history timeline. */
+ historyNonce: number;
+ /** On-device caption / translation state (§12), or null when no call is live. */
+ captions: CaptionUiState | null;
+ /** Whether this browser can do on-device captions at all (drives the disabled toggle). */
+ captionsSupported: boolean;
+}
+
+export interface SessionFactoryParams {
+ role: CallRole;
+ selfUserId: string;
+ peerId: string;
+ matchId: string;
+ media: CallMedia;
+ callId?: string;
+ callerDeviceId?: string;
+ handlers: CallSessionHandlers;
+}
+
+export interface GlobalCallControllerDeps {
+ currentUserId: string;
+ /** Subscribe to the signed-in user's call events and decline a ringing call. */
+ subscribeToCallEvents(userId: string, onEvent: (event: CallEvent) => void): () => void;
+ declineCall(callId: string): Promise;
+ /** Build a {@link CallSession} wired to the browser's WebRTC + crypto, or null when unavailable. */
+ makeSession(params: SessionFactoryParams): CallSession | null;
+ /** Resolve the other participant of a match (for an INCOMING call we only have the matchId). */
+ resolvePeer(matchId: string): Promise<{ peerId: string; peerName: string }>;
+ /** Map a raw error to friendly copy. */
+ friendlyError(error: unknown): string;
+ /** On-device caption engines for this platform, or null when captions are unsupported. */
+ makeCaptionPorts?(): CaptionEnginePorts | null;
+ captionsSupported?: boolean;
+ /** D13: the local user's "Help translate my captions" setting (enables the sender fallback). */
+ getHelpTranslateCaptions?(): Promise;
+ /** Device language — detection fallback AND default caption target (D12). */
+ deviceLanguage?: string;
+ canSendScreen?: boolean;
+}
+
+type Listener = () => void;
+
+export class GlobalCallController {
+ private readonly deps: GlobalCallControllerDeps;
+ private readonly listeners = new Set();
+ private unsubscribe: (() => void) | null = null;
+
+ private session: CallSession | null = null;
+ private captionSession: CaptionSession | null = null;
+ /** Guards the async peer resolution for an incoming call against races. */
+ private incomingToken = 0;
+
+ private snapshot: GlobalCallSnapshot;
+
+ constructor(deps: GlobalCallControllerDeps) {
+ this.deps = deps;
+ this.snapshot = {
+ active: false,
+ state: 'idle',
+ media: 'AUDIO',
+ activeMatchId: null,
+ peerName: '',
+ incoming: null,
+ localStream: null,
+ remoteStream: null,
+ screenStream: null,
+ sas: null,
+ muted: false,
+ cameraOff: false,
+ screenShareState: 'none',
+ screenRequest: null,
+ canSendScreen: Boolean(deps.canSendScreen),
+ lastEndReason: null,
+ error: null,
+ historyNonce: 0,
+ captions: null,
+ captionsSupported: Boolean(deps.captionsSupported),
+ };
+ }
+
+ // ── Store surface ─────────────────────────────────────────────────────────────
+
+ getSnapshot(): GlobalCallSnapshot {
+ return this.snapshot;
+ }
+
+ subscribe(listener: Listener): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ private set(patch: Partial): void {
+ this.snapshot = { ...this.snapshot, ...patch };
+ for (const listener of this.listeners) listener();
+ }
+
+ // ── Lifecycle ───────────────────────────────────────────────────────────────
+
+ /** Begin the single global call-event subscription. Safe to call once per signed-in user. */
+ start(): void {
+ if (this.unsubscribe || !this.deps.currentUserId) return;
+ this.unsubscribe = this.deps.subscribeToCallEvents(this.deps.currentUserId, (event) =>
+ this.onCallEvent(event)
+ );
+ }
+
+ /** Tear down the subscription and any live call. Call on sign-out / unmount. */
+ stop(): void {
+ this.unsubscribe?.();
+ this.unsubscribe = null;
+ void this.session?.hangup();
+ this.session = null;
+ this.disposeCaptions();
+ this.set({ incoming: null });
+ }
+
+ // ── Event routing (GLOBAL — no open-conversation filter) ────────────────────────
+
+ private onCallEvent(event: CallEvent): void {
+ if (event.kind === 'INCOMING') {
+ // A call rings regardless of which conversation (if any) is open.
+ if (this.session) return; // already in / setting up a call → the backend returns BUSY
+ void this.presentIncoming(event);
+ return;
+ }
+ const session = this.session;
+ if (session && (!session.getCallId() || session.getCallId() === event.callId)) {
+ void session.handleEvent(event);
+ }
+ }
+
+ private async presentIncoming(event: CallEvent): Promise {
+ const token = ++this.incomingToken;
+ let peerId = '';
+ let peerName = 'Your Match';
+ try {
+ const resolved = await this.deps.resolvePeer(event.matchId);
+ peerId = resolved.peerId;
+ peerName = resolved.peerName || 'Your Match';
+ } catch {
+ /* fall back to a generic label; the callee flow still gets callerDeviceId from the Call */
+ }
+ // Drop if a newer incoming/active call superseded this one while we resolved the peer.
+ if (token !== this.incomingToken || this.session) return;
+ this.set({
+ incoming: {
+ callId: event.callId,
+ matchId: event.matchId,
+ media: event.media ?? 'AUDIO',
+ callerDeviceId: event.fromDeviceId ?? undefined,
+ peerId,
+ peerName,
+ },
+ });
+ }
+
+ // ── Call actions ────────────────────────────────────────────────────────────
+
+ /** Start an outbound call from a conversation. */
+ async startCall(args: StartCallArgs): Promise {
+ if (this.session || !args.peerId) return;
+ const session = this.deps.makeSession({
+ role: 'caller',
+ selfUserId: this.deps.currentUserId,
+ peerId: args.peerId,
+ matchId: args.matchId,
+ media: args.media,
+ handlers: this.buildHandlers(),
+ });
+ if (!session) {
+ this.set({ error: 'Calling is not available in this browser.' });
+ return;
+ }
+ this.session = session;
+ this.set({ active: true, media: args.media, activeMatchId: args.matchId, peerName: args.peerName, error: null });
+ await session.start();
+ }
+
+ /** Accept the ringing incoming call (optionally answering a video call audio-only). */
+ async acceptIncoming(media?: CallMedia): Promise {
+ const incoming = this.snapshot.incoming;
+ if (!incoming || this.session || !incoming.peerId) return;
+ const chosen = media ?? incoming.media;
+ const session = this.deps.makeSession({
+ role: 'callee',
+ selfUserId: this.deps.currentUserId,
+ peerId: incoming.peerId,
+ matchId: incoming.matchId,
+ media: chosen,
+ callId: incoming.callId,
+ callerDeviceId: incoming.callerDeviceId,
+ handlers: this.buildHandlers(),
+ });
+ if (!session) {
+ this.set({ error: 'Calling is not available in this browser.' });
+ return;
+ }
+ this.session = session;
+ this.set({
+ active: true,
+ media: chosen,
+ activeMatchId: incoming.matchId,
+ peerName: incoming.peerName,
+ incoming: null,
+ error: null,
+ });
+ await session.accept(chosen);
+ }
+
+ /** Decline the ringing incoming call. */
+ async declineIncoming(): Promise {
+ const incoming = this.snapshot.incoming;
+ this.set({ incoming: null });
+ if (!incoming) return;
+ try {
+ await this.deps.declineCall(incoming.callId);
+ } catch (error) {
+ this.set({ error: this.deps.friendlyError(error) });
+ } finally {
+ this.set({ historyNonce: this.snapshot.historyNonce + 1 });
+ }
+ }
+
+ async hangup(): Promise {
+ await this.session?.hangup();
+ }
+
+ toggleMute(): void {
+ this.session?.toggleMute();
+ }
+ toggleCamera(): void {
+ this.session?.toggleCamera();
+ }
+ async startScreenShare(): Promise {
+ try {
+ await this.session?.startScreenShare();
+ } catch (error) {
+ this.set({ error: this.deps.friendlyError(error) });
+ }
+ }
+ stopScreenShare(): void {
+ this.session?.stopScreenShare();
+ }
+ acceptScreenShare(): void {
+ const id = this.snapshot.screenRequest;
+ if (id) {
+ this.session?.acceptScreenShare(id);
+ this.set({ screenRequest: null });
+ }
+ }
+ declineScreenShare(): void {
+ const id = this.snapshot.screenRequest;
+ if (id) {
+ this.session?.declineScreenShare(id);
+ this.set({ screenRequest: null });
+ }
+ }
+
+ clearError(): void {
+ this.set({ error: null });
+ }
+ clearEnded(): void {
+ this.set({ lastEndReason: null });
+ }
+
+ // ── Captions (§12) ────────────────────────────────────────────────────────────
+
+ async enableCaptions(): Promise {
+ await this.captionSession?.enableLocalCaptions();
+ }
+ disableCaptions(): void {
+ this.captionSession?.disableLocalCaptions();
+ }
+ async setCaptionLanguage(lang: string): Promise {
+ await this.captionSession?.setTargetLanguage(lang);
+ }
+
+ private async initCaptions(): Promise {
+ if (this.captionSession || !this.deps.makeCaptionPorts || !this.deps.captionsSupported) return;
+ const ports = this.deps.makeCaptionPorts();
+ if (!ports) return;
+ const helpTranslateCaptions = (await this.deps.getHelpTranslateCaptions?.()) ?? false;
+ const session = this.session;
+ if (!session) return;
+ this.captionSession = new CaptionSession({
+ ports,
+ send: (message) => session.sendCaptionControlMessage(message),
+ onChange: (state) => this.set({ captions: state }),
+ deviceLanguage: this.deps.deviceLanguage ?? 'en-US',
+ helpTranslateCaptions,
+ });
+ await this.captionSession.refreshAvailability();
+ }
+
+ private disposeCaptions(): void {
+ this.captionSession?.dispose();
+ this.captionSession = null;
+ }
+
+ // ── Session handlers → snapshot ──────────────────────────────────────────────
+
+ private buildHandlers(): CallSessionHandlers {
+ return {
+ onStateChange: (state) => this.set({ state }),
+ onLocalStream: (localStream) => this.set({ localStream }),
+ onRemoteStream: (remoteStream) => this.set({ remoteStream }),
+ onScreenStream: (screenStream) => this.set({ screenStream }),
+ onSasChange: (sas) => this.set({ sas }),
+ onScreenShareRequest: (screenRequest) => this.set({ screenRequest }),
+ onScreenShareStateChange: (screenShareState) => this.set({ screenShareState }),
+ onMuteChange: (muted) => this.set({ muted }),
+ onCameraChange: (cameraOff) => this.set({ cameraOff }),
+ onControlChannelOpen: () => void this.initCaptions(),
+ onCaptionControlMessage: (raw) => {
+ const parsed = parseCaptionWire(raw);
+ if (parsed) void this.captionSession?.handleWire(parsed);
+ },
+ onError: (error) => this.set({ error: this.deps.friendlyError(error) }),
+ onEnded: (reason) => this.onCallEnded(reason),
+ };
+ }
+
+ private onCallEnded(reason: CallEndReason | null): void {
+ this.disposeCaptions();
+ this.session = null;
+ this.set({
+ lastEndReason: reason,
+ active: false,
+ state: 'idle',
+ activeMatchId: null,
+ localStream: null,
+ remoteStream: null,
+ screenStream: null,
+ sas: null,
+ muted: false,
+ cameraOff: false,
+ screenShareState: 'none',
+ screenRequest: null,
+ captions: null,
+ historyNonce: this.snapshot.historyNonce + 1,
+ });
+ }
+}
diff --git a/src/lib/call-session.ts b/src/lib/call-session.ts
index 6e8486f..1f57dfc 100644
--- a/src/lib/call-session.ts
+++ b/src/lib/call-session.ts
@@ -28,6 +28,7 @@ import {
parseCallSignal,
type CallIceCandidate,
} from './call-signal';
+import { isCaptionWireType } from './call-captions';
import type { Call, CallEndReason, CallEvent, CallMedia, IceServerConfig } from '../api/calls';
export const RING_TIMEOUT_MS = 45_000;
@@ -109,6 +110,17 @@ export interface CallSessionHandlers {
onScreenShareStateChange?(state: ScreenShareState): void;
onMuteChange?(muted: boolean): void;
onCameraChange?(cameraOff: boolean): void;
+ /**
+ * The `control` channel opened — the caption layer (§12) can now start sending/receiving caption
+ * messages through {@link CallSession.sendCaptionControlMessage}.
+ */
+ onControlChannelOpen?(): void;
+ /**
+ * A caption-family `control` message arrived (`caption`, `caption.state`, `translate.request`,
+ * `translate.response`, `capabilities`). The raw JSON object is forwarded verbatim; the caption
+ * layer parses it with `parseCaptionWire`. CallSession never inspects caption TEXT (§12.4 / D9).
+ */
+ onCaptionControlMessage?(message: unknown): void;
onEnded?(reason: CallEndReason | null): void;
onError?(error: Error): void;
}
@@ -591,10 +603,26 @@ export class CallSession {
canSendScreen: this.canSendScreen(),
canReceiveScreen: true,
});
+ // The caption layer (§12) may now start sending/receiving over this same channel.
+ this.handlers.onControlChannelOpen?.();
};
channel.onmessage = (event) => this.onControlMessage(event.data);
}
+ /**
+ * Send a caption-family message (§12.3) to the one peer over the DTLS `control` channel. Used by
+ * the on-device caption layer. The payload is E2EE end to end over the relay like every other
+ * control message, so the backend never sees caption text. A no-op if the channel is not open.
+ */
+ sendCaptionControlMessage(message: object): void {
+ this.sendControl(message as ControlMessage);
+ }
+
+ /** Whether the `control` channel is open (captions can flow). */
+ isControlChannelOpen(): boolean {
+ return this.controlChannel?.readyState === 'open';
+ }
+
private onControlMessage(data: unknown): void {
let message: ControlMessage;
try {
@@ -602,6 +630,13 @@ export class CallSession {
} catch {
return; // Unknown / non-JSON control payloads are ignored (forward-compatibility).
}
+ // §12 captions / translation ride this same `control` channel. Forward caption-family messages
+ // verbatim to the caption layer (which owns the §12.3 wire format); CallSession never reads the
+ // caption TEXT, so the backend still cannot tell captions were used (§9.1 / D9).
+ if (isCaptionWireType(message.type)) {
+ this.handlers.onCaptionControlMessage?.(message);
+ return;
+ }
switch (message.type) {
case 'capabilities':
break; // capability info; the UI reads canSendScreen() locally for now.
@@ -629,10 +664,6 @@ export class CallSession {
case 'screen.stop-request':
if (this.screenShareState === 'sending') this.stopScreenShare();
break;
- // TODO(captions): §12 live captions / translation ride this same `control` channel
- // (message types `caption`, `caption.state`, `translate.request`, `translate.response`).
- // Out of scope for this pass; wire the handlers here when captions land. On-device only —
- // caption/translation text must NEVER be sent to any cloud service.
default:
break;
}
diff --git a/src/tests/call-captions.test.ts b/src/tests/call-captions.test.ts
new file mode 100644
index 0000000..e5ccd9d
--- /dev/null
+++ b/src/tests/call-captions.test.ts
@@ -0,0 +1,530 @@
+import { readFileSync } from 'node:fs';
+import { describe, expect, it, vi } from 'vitest';
+import {
+ baseLanguage,
+ buildCaption,
+ buildCapabilities,
+ buildCaptionState,
+ buildTranslateRequest,
+ buildTranslateResponse,
+ CAPTION_PROTOCOL_VERSION,
+ CaptionSession,
+ type CaptionEnginePorts,
+ type CaptionUiState,
+ type CaptionWireMessage,
+ type DeviceAvailability,
+ isCaptionWireType,
+ isSameLanguage,
+ parseCaptionWire,
+ pickSourceLanguage,
+ pickTargetLanguage,
+ type RecognitionHandle,
+ type RecognitionPort,
+ selectRecognitionEngine,
+ selectTranslationStrategy,
+ type TranslatorHandle,
+ type TranslatorPort,
+ type LanguageDetectorPort,
+} from '../lib/call-captions';
+
+/**
+ * Pure-protocol + engine-selection tests for on-device captions + translation
+ * (OneHookBackend/docs/voice-video-calls.md §12). These run under Node with no DOM: every browser
+ * engine is an injected fake. The HARD RULE — never a cloud recogniser or cloud translator (§12.2 /
+ * §12.5) — is asserted two ways: (1) the pure selectors have no cloud branch, so an unavailable
+ * device yields `unavailable`, never a remote fallback; (2) a source scan proves the modules never
+ * reference a cloud engine and always set `processLocally = true`.
+ */
+
+// ── Wire protocol ───────────────────────────────────────────────────────────────────────────────
+
+describe('caption wire protocol (§12.3)', () => {
+ it('builders stamp the current type and version', () => {
+ expect(buildCaption({ seq: 1, lang: 'en-IN', final: true, text: 'hi' })).toEqual({
+ type: 'caption',
+ v: CAPTION_PROTOCOL_VERSION,
+ seq: 1,
+ lang: 'en-IN',
+ final: true,
+ text: 'hi',
+ });
+ expect(buildCaptionState({ enabled: true, lang: 'en' })).toMatchObject({ type: 'caption.state', v: 1, enabled: true });
+ expect(buildTranslateRequest('en')).toEqual({ type: 'translate.request', v: 1, targetLang: 'en' });
+ expect(buildTranslateResponse(false, 'busy')).toEqual({ type: 'translate.response', v: 1, ok: false, reason: 'busy' });
+ expect(buildCapabilities([{ source: 'ta', target: 'en' }])).toMatchObject({ type: 'capabilities', v: 1 });
+ });
+
+ it('recognises the caption-family control types', () => {
+ for (const t of ['caption', 'caption.state', 'translate.request', 'translate.response', 'capabilities']) {
+ expect(isCaptionWireType(t)).toBe(true);
+ }
+ for (const t of ['screen.request', 'screen.accept', 'capabilities ', 'offer', '', null, 42]) {
+ expect(isCaptionWireType(t)).toBe(false);
+ }
+ });
+
+ it('parses a valid caption and copies only known fields', () => {
+ const parsed = parseCaptionWire(
+ JSON.stringify({
+ type: 'caption',
+ v: 1,
+ seq: 7,
+ lang: 'ta-IN',
+ final: true,
+ text: 'vanakkam',
+ tStartMs: 1234,
+ tr: { lang: 'en', text: 'hello', engine: 'device' },
+ attackerField: 'ignored',
+ })
+ );
+ expect(parsed).toEqual({
+ type: 'caption',
+ v: 1,
+ seq: 7,
+ lang: 'ta-IN',
+ final: true,
+ text: 'vanakkam',
+ tStartMs: 1234,
+ tr: { lang: 'en', text: 'hello', engine: 'device' },
+ });
+ expect((parsed as unknown as Record).attackerField).toBeUndefined();
+ });
+
+ it('parses caption.state / translate.* / capabilities', () => {
+ expect(parseCaptionWire({ type: 'caption.state', v: 1, enabled: true, wantLang: 'en' })).toEqual({
+ type: 'caption.state',
+ v: 1,
+ enabled: true,
+ wantLang: 'en',
+ });
+ expect(parseCaptionWire({ type: 'caption.state', v: 1, wantLang: null })).toEqual({
+ type: 'caption.state',
+ v: 1,
+ wantLang: null,
+ });
+ expect(parseCaptionWire({ type: 'translate.request', v: 1, targetLang: 'en' })).toEqual({
+ type: 'translate.request',
+ v: 1,
+ targetLang: 'en',
+ });
+ expect(parseCaptionWire({ type: 'translate.response', v: 1, ok: true })).toEqual({
+ type: 'translate.response',
+ v: 1,
+ ok: true,
+ });
+ expect(
+ parseCaptionWire({ type: 'capabilities', v: 1, translatePairs: [{ source: 'ta', target: 'en' }, { bad: 1 }] })
+ ).toEqual({ type: 'capabilities', v: 1, translatePairs: [{ source: 'ta', target: 'en' }] });
+ });
+
+ it('rejects wrong version, unknown type, bad JSON and missing required fields', () => {
+ expect(parseCaptionWire('{ not json')).toBeNull();
+ expect(parseCaptionWire(null)).toBeNull();
+ expect(parseCaptionWire({ type: 'caption', v: 2, seq: 1, lang: 'en', final: true, text: 'x' })).toBeNull();
+ expect(parseCaptionWire({ type: 'screen.request', v: 1 })).toBeNull();
+ expect(parseCaptionWire({ type: 'caption', v: 1, seq: 'x', lang: 'en', final: true, text: 'x' })).toBeNull();
+ expect(parseCaptionWire({ type: 'caption', v: 1, seq: 1, lang: 'en', final: 'yes', text: 'x' })).toBeNull();
+ expect(parseCaptionWire({ type: 'translate.request', v: 1, targetLang: '' })).toBeNull();
+ expect(parseCaptionWire({ type: 'translate.response', v: 1 })).toBeNull();
+ });
+});
+
+// ── Language helpers (D12) ────────────────────────────────────────────────────────────────────────
+
+describe('language helpers (D12)', () => {
+ it('reduces to the base subtag', () => {
+ expect(baseLanguage('en-IN')).toBe('en');
+ expect(baseLanguage('ZH-Hant-TW')).toBe('zh');
+ expect(baseLanguage(null)).toBe('');
+ });
+
+ it('compares languages by base subtag', () => {
+ expect(isSameLanguage('en-IN', 'en-US')).toBe(true);
+ expect(isSameLanguage('ta', 'en')).toBe(false);
+ expect(isSameLanguage('', 'en')).toBe(false);
+ });
+
+ it('auto-detects source, falling back only when detection is unavailable', () => {
+ expect(pickSourceLanguage('ta-IN', 'en-US')).toBe('ta-IN');
+ expect(pickSourceLanguage(null, 'en-US')).toBe('en-US');
+ expect(pickSourceLanguage('', 'en-US')).toBe('en-US');
+ });
+
+ it('defaults the target to the device locale, honouring an in-call override', () => {
+ expect(pickTargetLanguage('en-US')).toBe('en-US');
+ expect(pickTargetLanguage('en-US', 'hi-IN')).toBe('hi-IN');
+ expect(pickTargetLanguage('en-US', null)).toBe('en-US');
+ });
+});
+
+// ── Recognition engine selection (§12.2) ────────────────────────────────────────────────────────────
+
+describe('selectRecognitionEngine — on-device only (§12.2)', () => {
+ it('is unavailable when the browser has no local recogniser', () => {
+ const d = selectRecognitionEngine({ supported: false, availability: 'available' });
+ expect(d).toEqual({ status: 'unavailable', reason: 'Not supported on this device' });
+ });
+
+ it('is ready (processLocally) when the pack is installed', () => {
+ const d = selectRecognitionEngine({ supported: true, availability: 'available' });
+ expect(d).toEqual({ status: 'ready', processLocally: true });
+ });
+
+ it('offers install / installing without leaving on-device', () => {
+ expect(selectRecognitionEngine({ supported: true, availability: 'downloadable' })).toMatchObject({
+ status: 'needs-install',
+ processLocally: true,
+ });
+ expect(selectRecognitionEngine({ supported: true, availability: 'downloading' })).toMatchObject({
+ status: 'installing',
+ processLocally: true,
+ });
+ });
+
+ it('NEVER selects a cloud recogniser: every non-unavailable result is processLocally:true', () => {
+ const availabilities: DeviceAvailability[] = ['available', 'downloadable', 'downloading', 'unavailable'];
+ for (const supported of [true, false]) {
+ for (const availability of availabilities) {
+ const d = selectRecognitionEngine({ supported, availability });
+ if (d.status === 'unavailable') {
+ expect(d).not.toHaveProperty('processLocally', false);
+ } else {
+ expect(d.processLocally).toBe(true);
+ }
+ // There is no "cloud" status in the union at all.
+ expect(['ready', 'needs-install', 'installing', 'unavailable']).toContain(d.status);
+ }
+ }
+ });
+});
+
+// ── Translation strategy selection (§12.5) ──────────────────────────────────────────────────────────
+
+describe('selectTranslationStrategy — receiver-first, sender fallback, never cloud (§12.5)', () => {
+ const base = {
+ sourceLang: 'ta-IN',
+ targetLang: 'en-US' as string | null,
+ translatorSupported: true,
+ receiverAvailability: 'available' as DeviceAvailability,
+ senderHelpEnabled: false,
+ senderCanTranslate: false,
+ };
+
+ it('is none when there is no target or the caption is already in the target language', () => {
+ expect(selectTranslationStrategy({ ...base, targetLang: null })).toEqual({ mode: 'none' });
+ expect(selectTranslationStrategy({ ...base, sourceLang: 'en-IN', targetLang: 'en-US' })).toEqual({ mode: 'none' });
+ });
+
+ it('translates on the receiver when the pair is available locally', () => {
+ expect(selectTranslationStrategy(base)).toEqual({ mode: 'receiver' });
+ });
+
+ it('still routes to the receiver with a needed download', () => {
+ expect(selectTranslationStrategy({ ...base, receiverAvailability: 'downloadable' })).toEqual({
+ mode: 'receiver',
+ needsInstall: true,
+ });
+ expect(selectTranslationStrategy({ ...base, receiverAvailability: 'downloading' })).toEqual({
+ mode: 'receiver',
+ needsInstall: true,
+ });
+ });
+
+ it('falls back to the sender only when the speaker opted in AND can do the pair (D13)', () => {
+ expect(
+ selectTranslationStrategy({
+ ...base,
+ receiverAvailability: 'unavailable',
+ senderHelpEnabled: true,
+ senderCanTranslate: true,
+ })
+ ).toEqual({ mode: 'sender' });
+ });
+
+ it('is unavailable (original shown) when neither device can translate — NO cloud fallback', () => {
+ expect(selectTranslationStrategy({ ...base, receiverAvailability: 'unavailable' })).toMatchObject({
+ mode: 'unavailable',
+ });
+ // Sender opted in but cannot do the pair.
+ expect(
+ selectTranslationStrategy({
+ ...base,
+ receiverAvailability: 'unavailable',
+ senderHelpEnabled: true,
+ senderCanTranslate: false,
+ })
+ ).toMatchObject({ mode: 'unavailable' });
+ // Translator API entirely absent and no sender help → unavailable, never a remote call.
+ expect(
+ selectTranslationStrategy({ ...base, translatorSupported: false, receiverAvailability: 'unavailable' })
+ ).toMatchObject({ mode: 'unavailable' });
+ });
+
+ it('only ever returns one of the four on-device modes (no cloud mode exists)', () => {
+ const avail: DeviceAvailability[] = ['available', 'downloadable', 'downloading', 'unavailable'];
+ for (const supported of [true, false]) {
+ for (const receiverAvailability of avail) {
+ for (const senderHelpEnabled of [true, false]) {
+ for (const senderCanTranslate of [true, false]) {
+ const d = selectTranslationStrategy({
+ ...base,
+ translatorSupported: supported,
+ receiverAvailability,
+ senderHelpEnabled,
+ senderCanTranslate,
+ });
+ expect(['none', 'receiver', 'sender', 'unavailable']).toContain(d.mode);
+ }
+ }
+ }
+ }
+ });
+});
+
+// ── CaptionSession orchestration (DOM-free, injected fake engines) ───────────────────────────────────
+
+class FakeRecognition implements RecognitionPort {
+ availabilityValue: DeviceAvailability = 'available';
+ segment?: (s: { text: string; final: boolean }) => void;
+ stopped = false;
+ installCalls = 0;
+ async availability(): Promise {
+ return this.availabilityValue;
+ }
+ async install(): Promise {
+ this.installCalls += 1;
+ this.availabilityValue = 'available';
+ return this.availabilityValue;
+ }
+ async start(opts: {
+ lang: string;
+ onSegment(s: { text: string; final: boolean }): void;
+ onError(e: Error): void;
+ }): Promise {
+ this.segment = opts.onSegment;
+ return {
+ stop: () => {
+ this.stopped = true;
+ },
+ };
+ }
+}
+
+class FakeTranslator implements TranslatorPort {
+ availabilityValue: DeviceAvailability = 'available';
+ translateCalls: Array<{ source: string; target: string; text: string }> = [];
+ async availability(): Promise {
+ return this.availabilityValue;
+ }
+ async create(source: string, target: string): Promise {
+ return {
+ translate: async (text: string) => {
+ this.translateCalls.push({ source, target, text });
+ return `[${target}] ${text}`;
+ },
+ };
+ }
+}
+
+const detectorFor = (lang: string | null): LanguageDetectorPort => ({
+ availability: async () => (lang ? 'available' : 'unavailable'),
+ detect: async () => lang,
+});
+
+/** Flush all pending microtasks (recognition → detect → translate are chained awaits). */
+const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+function makeSession(opts: {
+ ports: CaptionEnginePorts;
+ helpTranslateCaptions?: boolean;
+ deviceLanguage?: string;
+ initialTargetLang?: string;
+}) {
+ const sent: CaptionWireMessage[] = [];
+ let state: CaptionUiState | null = null;
+ const session = new CaptionSession({
+ ports: opts.ports,
+ send: (m) => sent.push(m),
+ onChange: (s) => {
+ state = s;
+ },
+ deviceLanguage: opts.deviceLanguage ?? 'en-US',
+ helpTranslateCaptions: opts.helpTranslateCaptions ?? false,
+ initialTargetLang: opts.initialTargetLang,
+ });
+ return { session, sent, getState: () => state };
+}
+
+describe('CaptionSession — local captioning (§12.1)', () => {
+ it('starts on-device recognition and sends caption.state + caption segments', async () => {
+ const recognition = new FakeRecognition();
+ const detector = detectorFor('en-US');
+ const { session, sent } = makeSession({ ports: { recognition, translator: null, detector } });
+
+ await session.enableLocalCaptions();
+ expect(sent.find((m) => m.type === 'caption.state' && m.enabled === true)).toBeDefined();
+
+ recognition.segment!({ text: 'hello there', final: true });
+ await flush();
+
+ const caption = sent.find((m) => m.type === 'caption' && m.final) as Extract;
+ expect(caption).toBeDefined();
+ expect(caption.text).toBe('hello there');
+ expect(caption.lang).toBe('en-US');
+ });
+
+ it('installs the language pack when the pack is downloadable, then starts', async () => {
+ const recognition = new FakeRecognition();
+ recognition.availabilityValue = 'downloadable';
+ const { session } = makeSession({ ports: { recognition, translator: null, detector: null } });
+ await session.enableLocalCaptions();
+ expect(recognition.installCalls).toBe(1);
+ expect(session.getState().localEnabled).toBe(true);
+ });
+
+ it('cannot enable when there is no on-device recogniser (disabled with a reason)', async () => {
+ const { session } = makeSession({ ports: { recognition: null, translator: null, detector: null } });
+ await session.refreshAvailability();
+ const state = session.getState();
+ expect(state.localAvailable).toBe(false);
+ expect(state.localUnavailableReason).toBe('Not supported on this device');
+ await session.enableLocalCaptions();
+ expect(session.getState().localEnabled).toBe(false);
+ });
+});
+
+describe('CaptionSession — receiver translation (§12.5)', () => {
+ it('translates the peer caption on-device into the viewer target language', async () => {
+ const translator = new FakeTranslator();
+ const { session, getState } = makeSession({
+ ports: { recognition: null, translator, detector: null },
+ initialTargetLang: 'en-US',
+ });
+
+ await session.handleWire(buildCaption({ seq: 1, lang: 'ta-IN', final: true, text: 'vanakkam' }));
+
+ const line = getState()!.lines.find((l) => l.source === 'remote');
+ expect(line?.translationState).toBe('done');
+ expect(line?.translatedText).toBe('[en] vanakkam');
+ expect(translator.translateCalls).toEqual([{ source: 'ta', target: 'en', text: 'vanakkam' }]);
+ });
+
+ it('shows the original (never a cloud call) and asks the sender when the receiver cannot translate', async () => {
+ const translator = new FakeTranslator();
+ translator.availabilityValue = 'unavailable';
+ const { session, sent, getState } = makeSession({
+ ports: { recognition: null, translator, detector: null },
+ initialTargetLang: 'en-US',
+ });
+
+ await session.handleWire(buildCaption({ seq: 1, lang: 'ta-IN', final: true, text: 'vanakkam' }));
+ // No on-device receiver translation happened.
+ expect(translator.translateCalls).toEqual([]);
+ const line = getState()!.lines.find((l) => l.source === 'remote');
+ expect(line?.translationState).toBe('pending');
+ // It asked the sender to help (§12.5) rather than reaching any cloud service.
+ expect(sent.find((m) => m.type === 'translate.request')).toMatchObject({ targetLang: 'en-US' });
+
+ // The sender refuses → the viewer sees the original with "unavailable".
+ await session.handleWire(buildTranslateResponse(false, 'help-disabled'));
+ expect(getState()!.lines.find((l) => l.source === 'remote')?.translationState).toBe('unavailable');
+ });
+
+ it('uses the sender-attached translation (tr) without a local translator', async () => {
+ const { session, getState } = makeSession({
+ ports: { recognition: null, translator: null, detector: null },
+ initialTargetLang: 'en-US',
+ });
+ await session.handleWire(
+ buildCaption({ seq: 1, lang: 'ta-IN', final: true, text: 'vanakkam', tr: { lang: 'en', text: 'hello', engine: 'device' } })
+ );
+ const line = getState()!.lines.find((l) => l.source === 'remote');
+ expect(line?.translationState).toBe('done');
+ expect(line?.translatedText).toBe('hello');
+ });
+});
+
+describe('CaptionSession — sender fallback (D13)', () => {
+ it('declines the sender fallback when "Help translate my captions" is off', async () => {
+ const translator = new FakeTranslator();
+ const { session, sent } = makeSession({
+ ports: { recognition: null, translator, detector: null },
+ helpTranslateCaptions: false,
+ });
+ await session.handleWire(buildTranslateRequest('en'));
+ expect(sent.find((m) => m.type === 'translate.response')).toMatchObject({ ok: false, reason: 'help-disabled' });
+ });
+
+ it('accepts and attaches tr to its own final captions when help is on and the pair is supported', async () => {
+ const recognition = new FakeRecognition();
+ const translator = new FakeTranslator();
+ const { session, sent } = makeSession({
+ ports: { recognition, translator, detector: detectorFor('ta-IN') },
+ helpTranslateCaptions: true,
+ deviceLanguage: 'ta-IN',
+ });
+
+ await session.enableLocalCaptions();
+ await session.handleWire(buildTranslateRequest('en'));
+ expect(sent.find((m) => m.type === 'translate.response')).toMatchObject({ ok: true });
+
+ recognition.segment!({ text: 'vanakkam', final: true });
+ await flush();
+
+ const caption = sent
+ .filter((m): m is Extract => m.type === 'caption' && m.final)
+ .pop();
+ expect(caption?.tr).toMatchObject({ lang: 'en', engine: 'device' });
+ expect(caption?.tr?.text).toBe('[en] vanakkam');
+ });
+});
+
+describe('CaptionSession — ephemeral (D9)', () => {
+ it('drops all caption lines on dispose and stops recognition', async () => {
+ const recognition = new FakeRecognition();
+ const { session, getState } = makeSession({ ports: { recognition, translator: null, detector: null } });
+ await session.enableLocalCaptions();
+ await session.handleWire(buildCaption({ seq: 1, lang: 'en', final: true, text: 'hi' }));
+ expect(getState()!.lines.length).toBeGreaterThan(0);
+
+ session.dispose();
+ expect(recognition.stopped).toBe(true);
+ // A disposed session emits no further state and holds no caption text.
+ const before = getState();
+ await session.handleWire(buildCaption({ seq: 2, lang: 'en', final: true, text: 'again' }));
+ expect(getState()).toBe(before);
+ });
+});
+
+// ── No-cloud source guard (§12.2 / §12.5 HARD RULE) ─────────────────────────────────────────────────
+
+describe('no cloud recogniser / translator anywhere in the caption code', () => {
+ const read = (rel: string) => readFileSync(new URL(rel, import.meta.url), 'utf8');
+ const captionsSrc = read('../lib/call-captions.ts');
+ const webSrc = read('../lib/call-captions-web.ts');
+
+ it('never references a cloud speech/translation service', () => {
+ const forbidden = [
+ /Amazon\s*Transcribe/i,
+ /Amazon\s*Translate/i,
+ /Google\s*Cloud\s*Translation/i,
+ /translation\.googleapis/i,
+ /speech\.googleapis/i,
+ /cognitiveservices/i, // Azure Speech
+ /transcribe\.[a-z0-9-]+\.amazonaws/i,
+ ];
+ for (const src of [captionsSrc, webSrc]) {
+ for (const pattern of forbidden) {
+ expect(pattern.test(src)).toBe(false);
+ }
+ }
+ });
+
+ it('the web recogniser is only ever constructed with processLocally = true', () => {
+ // The adapter sets processLocally true and never false.
+ expect(/processLocally\s*=\s*true/.test(webSrc)).toBe(true);
+ expect(/processLocally\s*[:=]\s*false/.test(webSrc)).toBe(false);
+ // The on-device availability check is gated on processLocally: true.
+ expect(/processLocally:\s*true/.test(webSrc)).toBe(true);
+ });
+});
diff --git a/src/tests/call-controller.test.ts b/src/tests/call-controller.test.ts
new file mode 100644
index 0000000..9980ad2
--- /dev/null
+++ b/src/tests/call-controller.test.ts
@@ -0,0 +1,179 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ GlobalCallController,
+ type GlobalCallControllerDeps,
+ type SessionFactoryParams,
+} from '../lib/call-controller';
+import type { Call, CallEvent } from '../api/calls';
+
+/**
+ * Tests for the ONE app-level call orchestrator (OneHookBackend/docs/voice-video-calls.md §10).
+ *
+ * The defining behaviour of the global controller (vs. the old per-conversation hook) is that an
+ * INCOMING call rings on ANY screen — it is NEVER filtered by which conversation (if any) is open.
+ * These run under Node with injected fakes; no DOM, WebRTC or AppSync is touched.
+ */
+
+/** Flush the async peer-resolution microtask(s) in presentIncoming. */
+const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+/** A minimal stand-in for the WebRTC CallSession the controller drives. */
+class FakeSession {
+ readonly role: string;
+ readonly matchId: string;
+ callId: string | null;
+ started = false;
+ accepted: string | null = null;
+ hungUp = false;
+ constructor(params: SessionFactoryParams) {
+ this.role = params.role;
+ this.matchId = params.matchId;
+ this.callId = params.callId ?? null;
+ }
+ async start(): Promise {
+ this.started = true;
+ }
+ async accept(media: string): Promise {
+ this.accepted = media;
+ }
+ async hangup(): Promise {
+ this.hungUp = true;
+ }
+ getCallId(): string | null {
+ return this.callId;
+ }
+ async handleEvent(): Promise {}
+ toggleMute(): void {}
+ toggleCamera(): void {}
+ async startScreenShare(): Promise {}
+ stopScreenShare(): void {}
+ acceptScreenShare(): void {}
+ declineScreenShare(): void {}
+ sendCaptionControlMessage(): void {}
+}
+
+const incomingEvent = (overrides: Partial = {}): CallEvent => ({
+ userId: 'me',
+ callId: 'call-1',
+ matchId: 'match-1',
+ kind: 'INCOMING',
+ media: 'VIDEO',
+ fromDeviceId: 'caller-device',
+ at: 1,
+ ...overrides,
+});
+
+function harness(overrides: Partial = {}) {
+ let emit: (event: CallEvent) => void = () => {};
+ const sessions: FakeSession[] = [];
+ const declineCall = vi.fn(async () => ({ callId: 'call-1' }) as Call);
+ const deps: GlobalCallControllerDeps = {
+ currentUserId: 'me',
+ subscribeToCallEvents: (_userId, onEvent) => {
+ emit = onEvent;
+ return () => {};
+ },
+ declineCall,
+ makeSession: (params) => {
+ const session = new FakeSession(params);
+ sessions.push(session);
+ // The controller only needs the subset above; cast through unknown for the test.
+ return session as unknown as ReturnType;
+ },
+ resolvePeer: async () => ({ peerId: 'peer-user', peerName: 'Alex' }),
+ friendlyError: (error) => String(error),
+ ...overrides,
+ };
+ const controller = new GlobalCallController(deps);
+ controller.start();
+ return { controller, sessions, declineCall, emit: (e: CallEvent) => emit(e) };
+}
+
+describe('GlobalCallController — app-wide ringing (§10)', () => {
+ it('presents an INCOMING call as a ring modal with NO conversation open', async () => {
+ const { controller, emit } = harness();
+
+ emit(incomingEvent());
+ await flush();
+
+ const snap = controller.getSnapshot();
+ expect(snap.incoming).toMatchObject({
+ callId: 'call-1',
+ matchId: 'match-1',
+ media: 'VIDEO',
+ peerId: 'peer-user',
+ peerName: 'Alex',
+ callerDeviceId: 'caller-device',
+ });
+ // The modal is up but no call is active yet.
+ expect(snap.active).toBe(false);
+ });
+
+ it('rings for ANY match — it is never filtered by an open conversation', async () => {
+ const { controller, emit } = harness();
+
+ // A call for a match the user is not currently "looking at" still rings.
+ emit(incomingEvent({ callId: 'c-x', matchId: 'some-other-match' }));
+ await flush();
+
+ expect(controller.getSnapshot().incoming).toMatchObject({
+ callId: 'c-x',
+ matchId: 'some-other-match',
+ });
+ });
+
+ it('accepting builds a CALLEE session for the incoming call and dismisses the modal', async () => {
+ const { controller, sessions, emit } = harness();
+ emit(incomingEvent());
+ await flush();
+
+ await controller.acceptIncoming('AUDIO');
+
+ expect(sessions).toHaveLength(1);
+ expect(sessions[0].role).toBe('callee');
+ expect(sessions[0].matchId).toBe('match-1');
+ expect(sessions[0].accepted).toBe('AUDIO');
+ const snap = controller.getSnapshot();
+ expect(snap.incoming).toBeNull();
+ expect(snap.active).toBe(true);
+ });
+
+ it('declining calls declineCall and clears the modal', async () => {
+ const { controller, declineCall, emit } = harness();
+ emit(incomingEvent());
+ await flush();
+
+ await controller.declineIncoming();
+
+ expect(declineCall).toHaveBeenCalledWith('call-1');
+ expect(controller.getSnapshot().incoming).toBeNull();
+ });
+
+ it('suppresses a new INCOMING while a call is already active (backend returns BUSY)', async () => {
+ const { controller, sessions, emit } = harness();
+
+ await controller.startCall({ matchId: 'm9', peerId: 'p9', peerName: 'Sam', media: 'VIDEO' });
+ expect(controller.getSnapshot().active).toBe(true);
+ expect(sessions[0].role).toBe('caller');
+
+ emit(incomingEvent({ callId: 'c-busy', matchId: 'm-busy' }));
+ await flush();
+
+ // No ring modal is shown over the live call, and no second session is created.
+ expect(controller.getSnapshot().incoming).toBeNull();
+ expect(sessions).toHaveLength(1);
+ });
+
+ it('starts an outbound call from a conversation through the controller', async () => {
+ const { controller, sessions } = harness();
+
+ await controller.startCall({ matchId: 'm-out', peerId: 'p-out', peerName: 'Sam', media: 'AUDIO' });
+
+ expect(sessions).toHaveLength(1);
+ expect(sessions[0].role).toBe('caller');
+ expect(sessions[0].started).toBe(true);
+ const snap = controller.getSnapshot();
+ expect(snap.active).toBe(true);
+ expect(snap.activeMatchId).toBe('m-out');
+ });
+});