WolfDavid's picture
feat(01-03): add both transports, the standalone stage page and the Gradio component
f822421
Raw History Blame
5.14 kB
// avatar/avatar.js
//
// THE INLINE TRANSPORT: the renderer lives in the same document as the host component.
//
// Its ONLY job is to build a six-method stagePort and hand it to the shared
// installFacade. It implements no turn behaviour and it never assigns window.Avatar.
// tests/test_transport_seam.py fails if either rule is broken here.
import { getLastDecoded, playBuffer } from './audio-queue.js';
import { installFacade } from './facade.js';
import { makePlayer } from './lipsync.js';
import { createTurnLoop } from './turn-loop.js';
import { mountStage } from './vrm-stage.js';
/**
* A bus that exists before the real one does.
*
* mountStage() needs an emit callback, but the facade's event bus is only created
* once installFacade() runs - which is after the stage is mounted. Anything emitted
* during mount (notably the 'error' raised when a VRM has no expressionManager, the
* exact symptom of a double three.js instance) would otherwise be dropped on the
* floor. This buffers until connect() and then replays in order.
*/
function deferredBus() {
let sink = null;
const queued = [];
const emit = (name, data) => {
if (sink) sink(name, data);
else queued.push([name, data]);
};
emit.connect = (real) => {
sink = real;
while (queued.length > 0) {
const [name, data] = queued.shift();
sink(name, data);
}
};
return emit;
}
/**
* @param {HTMLElement} element the host-provided mount point
* @param {object} props at minimum { vrmUrl }
* @param {Function} [trigger] host event sink, so the backend also sees avatar events
* @param {object} [server] host bridge, handed to the turn loop as a getter
*/
export async function boot(element, props = {}, trigger = null, server = null) {
// Re-entry guard. key="vrm-stage" should stop the host re-running js_on_load at all,
// but if it ever does, returning the live object without remounting is what holds
// mountCount at 1 across 20 interactions (AVTR-01).
if (window.Avatar && window.Avatar.__debug && window.Avatar.__debug.ready) {
return window.Avatar;
}
const canvas = element.querySelector('#vrm-canvas');
if (!canvas) throw new Error('boot: no #vrm-canvas inside the mounted element');
const AudioCtor = window.AudioContext || window.webkitAudioContext;
const audioCtx = new AudioCtor();
const emit = deferredBus();
let stage = null;
let player = null;
let mounting = null;
let cached = null;
// Memoised: the stage is mounted exactly once no matter how many callers ask.
function ensureStage(vrmUrl) {
if (!mounting) {
mounting = (async () => {
stage = await mountStage(canvas, vrmUrl || props.vrmUrl, emit);
player = makePlayer(stage);
stage.setOnTick((dt) => player.tick(dt));
return stage;
})();
}
return mounting;
}
await ensureStage(props.vrmUrl);
// The stagePort is the ONLY thing that differs between transports. Here every
// method is a direct call; in the iframe transport every method is a round trip.
const stagePort = {
mount: (vrmUrl) => ensureStage(vrmUrl),
async speak(payload = {}) {
await ensureStage();
cached = payload;
return playBuffer(audioCtx, payload.audioUrl, emit, (when) =>
player.start(payload.timeline, audioCtx, when)
);
},
async replayCached() {
// The decoded buffer, not the URL: zero network requests, which is what
// plan 01-09's test_replay asserts with a browser request listener.
const buffer = getLastDecoded();
if (!buffer) throw new Error('nothing cached to re-play');
return playBuffer(audioCtx, buffer, emit, (when) =>
player.start(cached && cached.timeline, audioCtx, when)
);
},
setThinking: (value) => (stage ? stage.setThinking(value) : undefined),
setListening: (value) => (stage ? stage.setListening(value) : undefined),
getDebug: async () => (stage ? stage.getDebug() : {}),
};
const turnLoop = createTurnLoop({ stagePort, emit, getServer: () => server });
const installed = installFacade({
transport: 'inline',
stagePort,
turnLoop,
// The one host-aware line in the file: mirror avatar events onto the host's own
// event system so backend listeners see them too.
onEmit: (name, data) => {
if (typeof trigger === 'function') trigger(name, data);
},
});
emit.connect(installed.emit);
await installed.avatar.mount(props.vrmUrl);
return installed.avatar;
}
/**
* What the host's watch('value', ...) calls. Tolerates null, '' and already-parsed
* objects, because a component's initial value is empty and must not throw.
*/
export function onDirective(value) {
if (value === null || value === undefined || value === '') return null;
let directive = value;
if (typeof value === 'string') {
try {
directive = JSON.parse(value);
} catch {
return null;
}
}
if (!directive || typeof directive !== 'object') return null;
if (!window.Avatar) throw new Error('onDirective called before boot()');
return window.Avatar.speak(directive);
}