From 13e1a0863fc6e6a983b196f5246c8f72d33aae05 Mon Sep 17 00:00:00 2001 From: Leo Lara Date: Wed, 15 Jul 2026 13:59:36 -0400 Subject: [PATCH] feat(voice): add in-tree lifecycle hooks --- docs/voice-lifecycle-hooks.md | 42 +++++++++++ static/index.html | 3 + static/js/chat.js | 11 +++ static/js/tts-ai.js | 54 ++++++++++++-- static/js/voiceLifecycle.js | 112 ++++++++++++++++++++++++++++ static/js/voiceRecorder.js | 22 ++++++ static/sw.js | 3 +- tests/test_voice_lifecycle_hooks.py | 35 +++++++++ tests/voice_lifecycle.test.mjs | 111 +++++++++++++++++++++++++++ 9 files changed, 386 insertions(+), 7 deletions(-) create mode 100644 docs/voice-lifecycle-hooks.md create mode 100644 static/js/voiceLifecycle.js create mode 100644 tests/test_voice_lifecycle_hooks.py create mode 100644 tests/voice_lifecycle.test.mjs diff --git a/docs/voice-lifecycle-hooks.md b/docs/voice-lifecycle-hooks.md new file mode 100644 index 0000000000..ce9d17a118 --- /dev/null +++ b/docs/voice-lifecycle-hooks.md @@ -0,0 +1,42 @@ +# In-tree voice lifecycle hooks + +Odysseus exposes a small, core-owned browser contract for composing in-tree +voice interfaces with the existing recorder, chat stream, and TTS modules. It +does not expose an external plugin ABI or a remote module loader. + +## Mount and modules + +The app shell owns `#odysseus-voice-surface-root`. In-tree code may request it +with `getVoiceSurfaceRoot()` and may load only the fixed module IDs returned by +`listVoiceStaticModules()`. The module map contains source-controlled, +same-origin imports; callers cannot provide a URL, path, script, or package. + +Adding another module requires an ordinary reviewed source change. Runtime +discovery, arbitrary CSS or JavaScript injection, Python entry points, and +external wheels remain out of scope. + +## Lifecycle signal + +Subscribe with `subscribeVoiceLifecycle(listener)` or listen for the browser +event `odysseus:voice-lifecycle`. Each frozen payload has `version: 1`, a +bounded event `type`, `source`, `reason`, and an optional opaque `sessionId`. +Unknown events, fields, sources, reasons, and oversized identifiers fail +closed. + +Current event types are: + +- `capture-started` and `capture-stopped` +- `stream-complete` and `stream-interrupted` +- `tts-started` and `tts-idle` + +Signals contain lifecycle metadata only. They do not include transcripts, +audio, DOM content, model output, provider endpoints, credentials, or file +paths. Subscriber failures are isolated from the core recorder/chat/TTS path. + +## Compatibility scope + +The version describes the payload shape for in-tree consumers at the current +source revision. It is not a promise that an independently installed package +can load against future Odysseus versions. A stable external extension ABI +requires a separate trust, provenance, permission, update, and compatibility +decision. diff --git a/static/index.html b/static/index.html index cb30e84899..15a906aad7 100644 --- a/static/index.html +++ b/static/index.html @@ -2449,6 +2449,9 @@

Danger Zone

+ + + diff --git a/static/js/chat.js b/static/js/chat.js index 06c7a1dc7a..82f86d33b7 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -23,6 +23,7 @@ import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handle import createResearchSynapse from './researchSynapse.js'; import { createStreamRenderer } from './streamingRenderer.js'; import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composerArrowUpRecall.js'; +import { emitVoiceLifecycle } from './voiceLifecycle.js'; const RESEARCH_TIMEOUT_MS = 360000; const DEFAULT_TIMEOUT_MS = 120000; @@ -1741,6 +1742,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer if (data === '[DONE]') { _streamSawDone = true; + emitVoiceLifecycle('stream-complete', { + source: 'chat', + reason: 'completed', + sessionId: streamSessionId, + }); // Always update background map if entry exists (even if user switched back) var bgDone = _backgroundStreams.get(streamSessionId); if (bgDone && !_isBg) { @@ -3503,6 +3509,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // defeating the whole point. Only the Stop button cancels the server run. export function abortCurrentRequest(stopServer = false) { if (currentAbort) { + emitVoiceLifecycle('stream-interrupted', { + source: 'chat', + reason: stopServer ? 'user' : 'navigation', + ...(_streamSessionId ? { sessionId: _streamSessionId } : {}), + }); currentAbort.abort(); // Don't set to null here - let catch block handle it } diff --git a/static/js/tts-ai.js b/static/js/tts-ai.js index 9bb6f4012b..5d68f158e1 100644 --- a/static/js/tts-ai.js +++ b/static/js/tts-ai.js @@ -1,6 +1,8 @@ // static/js/tts-ai.js // AI Text-to-Speech Module — supports server TTS and browser Web Speech API +import { emitVoiceLifecycle } from './voiceLifecycle.js'; + class AITTSManager { constructor() { this.currentAudio = null; @@ -23,11 +25,24 @@ class AITTSManager { this._streamButton = null; this._streamResetFn = null; this._streamDebounceTimer = null; + this._lifecycleActive = false; // Check if TTS service is available this.checkAvailability(); } + _emitLifecycleStarted() { + if (this._lifecycleActive) return; + this._lifecycleActive = true; + emitVoiceLifecycle('tts-started', { source: 'tts', reason: 'started' }); + } + + _emitLifecycleIdle(reason = 'completed', force = false) { + if (!this._lifecycleActive && !force) return; + this._lifecycleActive = false; + emitVoiceLifecycle('tts-idle', { source: 'tts', reason }); + } + async checkAvailability() { try { // Check user setting first — if TTS is disabled in settings, don't show buttons @@ -173,7 +188,7 @@ class AITTSManager { async play(text) { // Stop current audio if playing - this.stop(); + this.stop('stopped'); const plainText = this.extractPlainText(text); if (!plainText) return; @@ -186,13 +201,24 @@ class AITTSManager { const audioUrl = await this.synthesize(text); this.currentAudio = new Audio(audioUrl); - await this.currentAudio.play(); + const audio = this.currentAudio; + audio.addEventListener('ended', () => { + if (this.currentAudio === audio) this.currentAudio = null; + this.isPlaying = false; + this._emitLifecycleIdle('completed'); + }, { once: true }); + audio.addEventListener('error', () => { + if (this.currentAudio === audio) this.currentAudio = null; + this.isPlaying = false; + this._emitLifecycleIdle('error'); + }, { once: true }); + await audio.play(); this.isPlaying = true; - // Note: onended should be set by the caller (addAITTSButton) - // to reset button state when audio finishes + this._emitLifecycleStarted(); } catch (error) { console.error('Failed to play audio:', error); + this._emitLifecycleIdle('error', true); throw error; } } @@ -206,19 +232,28 @@ class AITTSManager { utterance.onend = () => { this.isPlaying = false; + this._emitLifecycleIdle('completed'); resolve(); }; utterance.onerror = (e) => { this.isPlaying = false; + this._emitLifecycleIdle('error'); reject(new Error('Browser TTS error: ' + e.error)); }; window.speechSynthesis.speak(utterance); this.isPlaying = true; + this._emitLifecycleStarted(); }); } - stop() { + stop(reason = 'user') { + const hadActivity = this._lifecycleActive + || this.isPlaying + || this._processing + || this._streamActive + || this._queue.length > 0 + || !!this.currentAudio; // Cancel streaming TTS this._streamActive = false; if (this._streamDebounceTimer) { @@ -244,6 +279,7 @@ class AITTSManager { this.currentAudio = null; this.isPlaying = false; } + this._emitLifecycleIdle(reason, hadActivity); } /** @@ -317,11 +353,13 @@ class AITTSManager { audio.onended = () => { this.isPlaying = false; if (this.currentAudio === audio) this.currentAudio = null; + this._emitLifecycleIdle('completed'); resolve(); }; audio.onerror = (e) => { this.isPlaying = false; if (this.currentAudio === audio) this.currentAudio = null; + this._emitLifecycleIdle('error'); reject(new Error('Audio playback error')); }; audio.onpause = () => { @@ -331,9 +369,13 @@ class AITTSManager { }; audio.play().then(() => { this.isPlaying = true; + this._emitLifecycleStarted(); }).catch(reject); }); } + } catch (error) { + this._emitLifecycleIdle('error', true); + throw error; } finally { if (resetFn) resetFn(); } @@ -511,7 +553,7 @@ export function addAITTSButton(messageElement, text) { // Stop audio when navigating away window.addEventListener('beforeunload', () => { if (window.aiTTSManager) { - window.aiTTSManager.stop(); + window.aiTTSManager.stop('navigation'); } }); diff --git a/static/js/voiceLifecycle.js b/static/js/voiceLifecycle.js new file mode 100644 index 0000000000..b161170106 --- /dev/null +++ b/static/js/voiceLifecycle.js @@ -0,0 +1,112 @@ +// Core-owned lifecycle hooks for in-tree voice surfaces. +// +// This is deliberately not a plugin loader. The only loadable modules are +// fixed same-origin imports owned by the application source tree. + +export const VOICE_LIFECYCLE_VERSION = 1; +export const VOICE_SURFACE_ROOT_ID = 'odysseus-voice-surface-root'; + +const EVENT_TYPES = new Set([ + 'capture-started', + 'capture-stopped', + 'stream-complete', + 'stream-interrupted', + 'tts-started', + 'tts-idle', +]); +const SOURCES = new Set(['chat', 'recorder', 'tts']); +const REASONS = new Set([ + 'completed', + 'denied', + 'error', + 'navigation', + 'started', + 'stopped', + 'unavailable', + 'user', +]); +const DETAIL_FIELDS = new Set(['reason', 'sessionId', 'source']); +const MAX_SESSION_ID_LENGTH = 128; +const listeners = new Set(); + +// Keep the module list source-controlled and statically analyzable. A caller +// can select only one of these IDs; it cannot provide a URL or module path. +const STATIC_MODULE_LOADERS = Object.freeze({ + recorder: () => import('./voiceRecorder.js'), + tts: () => import('./tts-ai.js'), +}); + +function boundedSessionId(value) { + if (value === undefined) return undefined; + if (typeof value !== 'string' || !value || value.length > MAX_SESSION_ID_LENGTH) { + throw new TypeError('sessionId must be a non-empty string of at most 128 characters'); + } + if (!/^[A-Za-z0-9._:-]+$/.test(value)) { + throw new TypeError('sessionId contains unsupported characters'); + } + return value; +} + +function normalizeDetail(type, detail) { + if (!EVENT_TYPES.has(type)) throw new TypeError(`Unknown voice lifecycle event: ${type}`); + if (detail === null || typeof detail !== 'object' || Array.isArray(detail)) { + throw new TypeError('Voice lifecycle detail must be an object'); + } + for (const key of Object.keys(detail)) { + if (!DETAIL_FIELDS.has(key)) throw new TypeError(`Unknown voice lifecycle field: ${key}`); + } + if (!SOURCES.has(detail.source)) throw new TypeError('Unknown voice lifecycle source'); + if (!REASONS.has(detail.reason)) throw new TypeError('Unknown voice lifecycle reason'); + + const payload = { + version: VOICE_LIFECYCLE_VERSION, + type, + source: detail.source, + reason: detail.reason, + }; + const sessionId = boundedSessionId(detail.sessionId); + if (sessionId !== undefined) payload.sessionId = sessionId; + return Object.freeze(payload); +} + +export function emitVoiceLifecycle(type, detail) { + const payload = normalizeDetail(type, detail); + for (const listener of listeners) { + try { + listener(payload); + } catch (error) { + console.error('Voice lifecycle listener failed:', error); + } + } + if (typeof window !== 'undefined' + && typeof window.dispatchEvent === 'function' + && typeof CustomEvent === 'function') { + window.dispatchEvent(new CustomEvent('odysseus:voice-lifecycle', { detail: payload })); + } + return payload; +} + +export function subscribeVoiceLifecycle(listener) { + if (typeof listener !== 'function') throw new TypeError('listener must be a function'); + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getVoiceSurfaceRoot() { + if (typeof document === 'undefined') throw new Error('Voice surface root requires a document'); + const root = document.getElementById(VOICE_SURFACE_ROOT_ID); + if (!root) throw new Error('App-owned voice surface root is unavailable'); + return root; +} + +export function listVoiceStaticModules() { + return Object.freeze(Object.keys(STATIC_MODULE_LOADERS)); +} + +export async function loadVoiceStaticModule(moduleId) { + if (typeof moduleId !== 'string' + || !Object.prototype.hasOwnProperty.call(STATIC_MODULE_LOADERS, moduleId)) { + throw new TypeError('Unknown app-owned voice module'); + } + return STATIC_MODULE_LOADERS[moduleId](); +} diff --git a/static/js/voiceRecorder.js b/static/js/voiceRecorder.js index ec45486327..b1a985f21d 100644 --- a/static/js/voiceRecorder.js +++ b/static/js/voiceRecorder.js @@ -1,5 +1,7 @@ // static/js/voiceRecorder.js +import { emitVoiceLifecycle } from './voiceLifecycle.js'; + /** * Voice recording with optional Speech-to-Text transcription. * @@ -15,6 +17,7 @@ let audioChunks = []; let isRecording = false; let recordingStartTime = null; let recordingInterval = null; +let recordingStopReason = 'completed'; // Browser STT state let _recognition = null; @@ -152,12 +155,14 @@ export function startRecording(onFileCreated, showToast, showError) { // Check for secure context (getUserMedia requires HTTPS or localhost) if (!window.isSecureContext) { if (showError) showError('Microphone requires HTTPS. Use a reverse proxy with SSL or access via localhost.'); + emitVoiceLifecycle('capture-stopped', { source: 'recorder', reason: 'unavailable' }); _resetRecordingUI(); return; } if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { if (showError) showError('Microphone not supported in this browser.'); + emitVoiceLifecycle('capture-stopped', { source: 'recorder', reason: 'unavailable' }); _resetRecordingUI(); return; } @@ -176,6 +181,11 @@ export function startRecording(onFileCreated, showToast, showError) { mediaRecorder.onstop = async () => { stream.getTracks().forEach(track => track.stop()); + emitVoiceLifecycle('capture-stopped', { + source: 'recorder', + reason: recordingStopReason, + }); + recordingStopReason = 'completed'; const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); const provider = _sttProvider; @@ -217,6 +227,11 @@ export function startRecording(onFileCreated, showToast, showError) { mediaRecorder.start(); isRecording = true; + recordingStopReason = 'completed'; + emitVoiceLifecycle('capture-started', { + source: 'recorder', + reason: 'started', + }); recordingStartTime = new Date(); // Start browser STT if that's the provider @@ -230,6 +245,12 @@ export function startRecording(onFileCreated, showToast, showError) { }) .catch(error => { console.error('Microphone access error:', error); + emitVoiceLifecycle('capture-stopped', { + source: 'recorder', + reason: error.name === 'NotAllowedError' + ? 'denied' + : (error.name === 'NotFoundError' ? 'unavailable' : 'error'), + }); if (showError) { if (error.name === 'NotAllowedError') { showError('Microphone access denied. Check browser permissions.'); @@ -248,6 +269,7 @@ export function startRecording(onFileCreated, showToast, showError) { */ export function stopRecording() { if (mediaRecorder && mediaRecorder.state === 'recording') { + recordingStopReason = 'user'; mediaRecorder.stop(); // isRecording will be set to false in _resetRecordingUI called from onstop } else { diff --git a/static/sw.js b/static/sw.js index 05b3d4e9b4..080ac23290 100644 --- a/static/sw.js +++ b/static/sw.js @@ -7,7 +7,7 @@ // - Other static assets (images/fonts/libs): cache-first with bg refresh. // - API / non-GET: never cached. // Bump CACHE_NAME whenever the precache list or SW logic changes. -const CACHE_NAME = 'odysseus-v344'; +const CACHE_NAME = 'odysseus-v345'; // Core shell precached on install so repeat opens are instant without any // network wait. Keep this list in sync with the