Spaces:
Running on Zero
Running on Zero
Download avatar/avatar.js from WolfDavid/japanese-learning-avatar: direct link, hf CLI and curl.
- Browser
- Download file 5.14 kB
-
https://huggingface.co/spaces/WolfDavid/japanese-learning-avatar/resolve/f13c9e2db20eb1318242fc3e3dcebe3a465e367d/avatar/avatar.js
- Command line
-
hf download hf://spaces/WolfDavid/japanese-learning-avatar@f13c9e2db20eb1318242fc3e3dcebe3a465e367d/avatar/avatar.js
-
curl -L -o avatar.js https://huggingface.co/spaces/WolfDavid/japanese-learning-avatar/resolve/f13c9e2db20eb1318242fc3e3dcebe3a465e367d/avatar/avatar.js
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); | |
| } | |