| /** | |
| * 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+<ms>] {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; | |
| } | |
| } | |