/** * Structured call-lifecycle logger + shared TTS ownership. * * Three pieces, shipped together because they're tightly coupled: * * clog(evt) Namespaced, grep-friendly console log * for every call-related event. Format * ``[Call+] {e:…, …}`` so * ``grep "\\[Call\\]"`` surfaces a * chronological trace of any session. * * speakOwned(source, text) Single-owner TTS dispatch. Both the * overlay's fallback opener and the * App-level assistant-reply effect route * through this function, which guards * on ``window.__hp_tts_owner__`` so * duplicate sources don't double-speak * the same turn. First to call wins; * the second sees ``owner !== source`` * and drops cleanly (emits an * ``tts: dedupe`` log so the drop is * auditable). * * isCallFullDuplexEnabled() Feature flag gate. Defaults to ON; a * user can force-off via * ``localStorage.homepilot_call_full_duplex`` * or a build-time ``VITE_CALL_FULL_DUPLEX_ENABLED``. * With the flag off every consumer keeps * its pre-flag behaviour — lets us flip * the full-duplex fix on/off during * dogfood without editing code. * * No runtime DOM setup; safe to import from SSR contexts — the * speakOwned path guards on ``typeof window``. */ export function clog(evt) { if (typeof performance === 'undefined') return; const ts = Math.floor(performance.now()); // console.info so DevTools 'Info' filter can hide it in prod if // needed; keeps the log present for debug without noise. // eslint-disable-next-line no-console console.info(`[Call+${ts}]`, evt); } /** Resolve the feature-flag state. Priority: * 1. ``localStorage.homepilot_call_full_duplex`` override * 2. build-time ``VITE_CALL_FULL_DUPLEX_ENABLED`` (default 'true') * Falls through to ``true`` on any read error. */ export function isCallFullDuplexEnabled() { if (typeof window === 'undefined') return true; try { const override = window.localStorage.getItem('homepilot_call_full_duplex'); if (override === 'true') return true; if (override === 'false') return false; } catch { /* ignore storage errors */ } const envVal = import.meta.env?.VITE_CALL_FULL_DUPLEX_ENABLED; return String(envVal ?? 'true') !== 'false'; } /** * Speak ``text`` via ``window.SpeechService`` with a shared owner * lock. Returns ``true`` if this call owns the utterance, ``false`` * if another owner was already speaking (dedupe). Hooks fire on * the owning path only. * * Fallback when the shim isn't available: emits the ``tts.start`` * log + an estimated ``tts.end`` timer anyway, so callers still * get their ``onEnd`` hook (used by the mic-gate release in * ``CallOverlay``) even with no TTS engine present. */ export function speakOwned(source, text, hooks) { if (typeof window === 'undefined') return false; const t = (text ?? '').trim(); if (!t) return false; const owner = window.__hp_tts_owner__; if (owner && owner !== source) { clog({ e: 'tts', action: 'dedupe', source, textLen: t.length }); return false; } window.__hp_tts_owner__ = source; try { hooks?.onStart?.(); clog({ e: 'tts', action: 'start', source, textLen: t.length }); const svc = window.SpeechService; svc?.speak?.(t); // Length-estimate for the onEnd release — the SpeechService shim // in this repo doesn't expose an explicit onend hook, so we // guesstimate from text length + cap it so a runaway payload // can't permanently hold the owner lock. ~12 chars/s typical; // [800 ms, 10 s] clamp. const estimateMs = Math.max(800, Math.min(10_000, t.length * 80)); window.setTimeout(() => { if (window.__hp_tts_owner__ === source) { window.__hp_tts_owner__ = null; } hooks?.onEnd?.(); clog({ e: 'tts', action: 'end', source, textLen: t.length }); }, estimateMs); return true; } catch (err) { if (window.__hp_tts_owner__ === source) { window.__hp_tts_owner__ = null; } const message = err instanceof Error ? err.message : String(err); hooks?.onError?.(message); clog({ e: 'error', where: 'speakOwned', message }); return false; } }