File size: 4,997 Bytes
921d377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
/**
 * 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;
    }
}