Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/voice-lifecycle-hooks.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2449,6 +2449,9 @@ <h2 style="color:#e55;">Danger Zone</h2>
<!-- Toast container (aria-live for screen readers) -->
<div id="toast" class="toast" role="status" aria-live="polite"></div>

<!-- Core-owned mount for reviewed, in-tree voice surfaces. -->
<div id="odysseus-voice-surface-root" hidden aria-hidden="true"></div>



<!-- Load modules in this order -->
Expand Down
11 changes: 11 additions & 0 deletions static/js/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down
54 changes: 48 additions & 6 deletions static/js/tts-ai.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
}
Expand All @@ -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) {
Expand All @@ -244,6 +279,7 @@ class AITTSManager {
this.currentAudio = null;
this.isPlaying = false;
}
this._emitLifecycleIdle(reason, hadActivity);
}

/**
Expand Down Expand Up @@ -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 = () => {
Expand All @@ -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();
}
Expand Down Expand Up @@ -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');
}
});

Expand Down
112 changes: 112 additions & 0 deletions static/js/voiceLifecycle.js
Original file line number Diff line number Diff line change
@@ -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]();
}
Loading