// avatar/facade.js // // THIS IS THE ONLY FILE IN avatar/ THAT ASSIGNS window.Avatar. // tests/test_transport_seam.py::test_only_facade_assigns_window_avatar fails if any // other module does. // // Why one builder rather than one per transport: the naive design has each transport // build its own window.Avatar, and then every later plan must remember to update both. // That seam rots silently - a method added to the inline transport and forgotten in the // fallback is invisible until the day the fallback is needed. Here the method list is // DATA (AVATAR_SURFACE), the object is built by iterating it, and an unresolved name // throws at boot with the missing name rather than being quietly absent. // // This module knows nothing about any host framework and nothing about the renderer. // It reaches the renderer only through the six-method stagePort it is handed. /** * The transport contract. The single source of truth for the method list. * Tests parse this array out of this file rather than duplicating it, so it cannot drift. * * mount (vrmUrl) -> Promise, resolves once the VRM is ready * speak ({audioUrl, timeline, expression, subtitle}) -> Promise, resolves at speech end * replay re-plays the cached directive, no network * setListening (bool) * setThinking (bool) * on (event, cb) where event is one of: * ready, speech-start, speech-end, error, asr-tier, transcript * getDebug -> Promise, async so a message-passing transport can answer * startListening push-to-talk down - body wired in plan 01-07 * stopListening push-to-talk up - body wired in plan 01-07 * dispatchTurn (text, {speed}) - body wired in plan 01-08 * requestSlower re-synthesise the last utterance slower - plan 01-08 */ export const AVATAR_SURFACE = [ 'mount', 'speak', 'replay', 'setListening', 'setThinking', 'on', 'getDebug', 'startListening', 'stopListening', 'dispatchTurn', 'requestSlower', ]; /** * Build window.Avatar out of a stagePort and a turn loop. * * @param {object} opts * @param {string} opts.transport 'inline' | 'iframe', recorded in __debug * @param {object} opts.stagePort mount, speak, replayCached, setThinking, setListening, getDebug * @param {object} opts.turnLoop the result of createTurnLoop() * @param {Function} [opts.onEmit] optional hook so a transport can forward events onward * @returns {{avatar: object, emit: Function, debug: object}} */ export function installFacade({ transport, stagePort, turnLoop, onEmit }) { if (!stagePort) throw new Error('installFacade: a stagePort is required'); if (!turnLoop) throw new Error('installFacade: a turnLoop is required'); const listeners = new Map(); const debug = { ready: false, mountCount: 0, vrmMetaTitle: '', vrmMeta: null, threeInstanceCount: 0, currentVisemes: { aa: 0, ih: 0, ou: 0, ee: 0, oh: 0 }, blinkValue: 0, breathValue: 0, clockOffset: 0, thinking: false, listening: false, lastTurnId: null, transport: transport || 'inline', }; function emit(name, data) { if (name === 'ready') debug.ready = true; const subs = listeners.get(name); if (subs) { for (const cb of [...subs]) { try { cb(data); } catch (err) { console.error(`Avatar listener for ${name} failed`, err); } } } if (typeof onEmit === 'function') { try { onEmit(name, data); } catch (err) { console.error('Avatar onEmit hook failed', err); } } } // The members the facade itself owns: the event bus, the mount counter, and the // merge that turns three independent debug sources into one readable object. const local = { on(event, cb) { if (typeof cb !== 'function') return () => {}; if (!listeners.has(event)) listeners.set(event, new Set()); listeners.get(event).add(cb); return () => listeners.get(event)?.delete(cb); }, async mount(a, b) { // The canvas belongs to the transport, so a URL is the only thing that has to // cross this boundary. Accept mount(url) and mount(canvas, url) alike. const vrmUrl = b === undefined ? a : b; debug.mountCount += 1; await stagePort.mount(vrmUrl); debug.ready = true; return local.getDebug(); }, async getDebug() { let stageDebug = null; try { stageDebug = await stagePort.getDebug(); } catch { stageDebug = null; // not mounted yet, or the far side has not answered } Object.assign(debug, turnLoop.state || {}, stageDebug || {}); return debug; }, }; // Build by ITERATING the surface list. This loop, and the throw under it, are what // make "both transports expose the same methods" a guarantee instead of a habit. const avatar = {}; const unresolved = []; for (const name of AVATAR_SURFACE) { let owner = null; if (typeof local[name] === 'function') owner = local; else if (typeof turnLoop[name] === 'function') owner = turnLoop; else if (typeof stagePort[name] === 'function') owner = stagePort; if (!owner) { unresolved.push(name); continue; } avatar[name] = (...args) => owner[name](...args); } if (unresolved.length > 0) { throw new Error( `installFacade: nothing implements ${unresolved.join(', ')} - every name in ` + 'AVATAR_SURFACE must resolve to a turn-loop method or a stagePort method' ); } avatar.__debug = debug; window.Avatar = avatar; return { avatar, emit, debug }; }