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