WolfDavid's picture
feat(01-03): add the single facade builder and the shared turn loop
7146e75
Raw History Blame
5.68 kB
// 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<debug>, 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 };
}