Spaces:
Running on Zero
Running on Zero
feat(01-03): add both transports, the standalone stage page and the Gradio component
Browse files- avatar/avatar.js: inline transport, 145 lines of stagePort plumbing only
- avatar/avatar-iframe.js: postMessage transport, same facade and same turn loop
- both use a deferred event bus so events emitted during mountStage (notably the
no-expressionManager error) survive until installFacade builds the real one
- avatar/stage.html: iframe target and standalone debug harness; ?demo=1 plays a
canned WAV against a hand-authored placeholder timeline
- avatar/assets/demo-konnichiwa.wav: 24000 Hz mono 16-bit, 1.129 s
- avatar_component.py + app.py: AVATAR_TRANSPORT selects the module in one place
- vrm-stage.js: reword the import-map comment so the stage core names no host
- ignore and un-lint Gradio's auto-generated .pyi component stub
- .gitignore +6 -0
- app.py +38 -0
- avatar/assets/demo-konnichiwa.wav +3 -0
- avatar/avatar-iframe.js +155 -0
- avatar/avatar.js +145 -0
- avatar/stage.html +188 -0
- avatar/vrm-stage.js +3 -2
- pyproject.toml +4 -1
- src/japanese_avatar/ui/avatar_component.py +56 -0
.gitignore
CHANGED
|
@@ -7,3 +7,9 @@ node_modules/
|
|
| 7 |
.env
|
| 8 |
voicevox_runtime/
|
| 9 |
test-results/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
.env
|
| 8 |
voicevox_runtime/
|
| 9 |
test-results/
|
| 10 |
+
|
| 11 |
+
# Gradio rewrites a .pyi type stub next to every module that subclasses a component
|
| 12 |
+
# (gradio/component_meta.py calls create_or_modify_pyi unconditionally at class
|
| 13 |
+
# creation, with no opt-out). It is regenerated on every import and its import order
|
| 14 |
+
# is not ours to fix, so it is build output, not source.
|
| 15 |
+
*.pyi
|
app.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Space entry point: assembly only.
|
| 2 |
+
|
| 3 |
+
No logic and no module-level mutable state lives here. Gradio shares module globals
|
| 4 |
+
across every visitor session, so the discipline starts now, while there is still
|
| 5 |
+
nothing to share.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
import gradio as gr
|
| 11 |
+
|
| 12 |
+
from japanese_avatar.ui.avatar_component import VrmStage
|
| 13 |
+
|
| 14 |
+
# Serves avatar/ straight off disk, bypassing the Gradio cache, which is how the
|
| 15 |
+
# browser reaches avatar.js, stage.html and tutor.vrm. Deliberately one dedicated
|
| 16 |
+
# directory: this call's own docstring warns that ALL files under a listed path
|
| 17 |
+
# become network-reachable.
|
| 18 |
+
gr.set_static_paths(["avatar"])
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def gpu_disabled() -> bool:
|
| 22 |
+
"""SC-4's kill switch: the whole turn loop must complete with DISABLE_GPU=1.
|
| 23 |
+
|
| 24 |
+
Read per call rather than captured at import so a Space restart with the variable
|
| 25 |
+
flipped takes effect without a code change.
|
| 26 |
+
"""
|
| 27 |
+
return os.environ.get("DISABLE_GPU", "0").strip().lower() in {"1", "true", "yes"}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def build_app() -> gr.Blocks:
|
| 31 |
+
"""Build the Blocks app. Importable and callable from tests without launching."""
|
| 32 |
+
with gr.Blocks(title="Japanese Learning Avatar", fill_height=True) as demo:
|
| 33 |
+
VrmStage()
|
| 34 |
+
return demo
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
if __name__ == "__main__":
|
| 38 |
+
build_app().launch()
|
avatar/assets/demo-konnichiwa.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4297f70e5b9e41c392c6f9f9457dd72b07470283a2bd038e1dd9d8cef4cdbdfa
|
| 3 |
+
size 54236
|
avatar/avatar-iframe.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// avatar/avatar-iframe.js
|
| 2 |
+
//
|
| 3 |
+
// THE IFRAME TRANSPORT: the renderer lives inside avatar/stage.html, reachable only
|
| 4 |
+
// by postMessage. This is the documented escape hatch for the inline spike.
|
| 5 |
+
//
|
| 6 |
+
// It is deliberately the same shape as avatar.js: build a six-method stagePort, then
|
| 7 |
+
// call the SAME createTurnLoop and the SAME installFacade. Every method below is
|
| 8 |
+
// message plumbing. There is no turn behaviour in this file, and there must never be
|
| 9 |
+
// - it belongs in avatar/turn-loop.js so both transports get it at once.
|
| 10 |
+
//
|
| 11 |
+
// Note for later waves: microphone capture and ASR run in the PARENT document under
|
| 12 |
+
// both transports, so nothing about them crosses this boundary. Only rendering and
|
| 13 |
+
// audio playback live inside the frame.
|
| 14 |
+
|
| 15 |
+
import { installFacade } from './facade.js';
|
| 16 |
+
import { createTurnLoop } from './turn-loop.js';
|
| 17 |
+
|
| 18 |
+
const STAGE_PATH = '/gradio_api/file=avatar/stage.html';
|
| 19 |
+
// speak() resolves at speech-end, so a reply can legitimately take an utterance's
|
| 20 |
+
// worth of time. This bound only exists so a dead frame fails loudly.
|
| 21 |
+
const REPLY_TIMEOUT_MS = 60000;
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* A bus that exists before the real one does. Identical in purpose to the one in
|
| 25 |
+
* avatar.js: the frame can report an error before installFacade() has built the event
|
| 26 |
+
* bus, and those events must not be lost. Buffers until connect(), then replays.
|
| 27 |
+
*/
|
| 28 |
+
function deferredBus() {
|
| 29 |
+
let sink = null;
|
| 30 |
+
const queued = [];
|
| 31 |
+
const emit = (name, data) => {
|
| 32 |
+
if (sink) sink(name, data);
|
| 33 |
+
else queued.push([name, data]);
|
| 34 |
+
};
|
| 35 |
+
emit.connect = (real) => {
|
| 36 |
+
sink = real;
|
| 37 |
+
while (queued.length > 0) {
|
| 38 |
+
const [name, data] = queued.shift();
|
| 39 |
+
sink(name, data);
|
| 40 |
+
}
|
| 41 |
+
};
|
| 42 |
+
return emit;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* @param {HTMLElement} element the host-provided mount point
|
| 47 |
+
* @param {object} props at minimum { vrmUrl }; optional { stageUrl }
|
| 48 |
+
* @param {Function} [trigger] host event sink
|
| 49 |
+
* @param {object} [server] host bridge, handed to the turn loop as a getter
|
| 50 |
+
*/
|
| 51 |
+
export async function boot(element, props = {}, trigger = null, server = null) {
|
| 52 |
+
if (window.Avatar && window.Avatar.__debug && window.Avatar.__debug.ready) {
|
| 53 |
+
return window.Avatar;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const emit = deferredBus();
|
| 57 |
+
|
| 58 |
+
const frame = document.createElement('iframe');
|
| 59 |
+
frame.title = 'avatar stage';
|
| 60 |
+
frame.allow = 'microphone; autoplay';
|
| 61 |
+
const stageUrl = props.stageUrl || STAGE_PATH;
|
| 62 |
+
frame.src = `${stageUrl}?vrm=${encodeURIComponent(props.vrmUrl || '')}`;
|
| 63 |
+
element.appendChild(frame);
|
| 64 |
+
|
| 65 |
+
const pending = new Map();
|
| 66 |
+
let seq = 0;
|
| 67 |
+
|
| 68 |
+
let announceReady = null;
|
| 69 |
+
const frameReady = new Promise((resolve) => {
|
| 70 |
+
announceReady = resolve;
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
window.addEventListener('message', (ev) => {
|
| 74 |
+
if (ev.source !== frame.contentWindow) return;
|
| 75 |
+
const msg = ev.data;
|
| 76 |
+
if (!msg || typeof msg.type !== 'string' || !msg.type.startsWith('avatar:')) return;
|
| 77 |
+
|
| 78 |
+
if (msg.type === 'avatar:frame-ready') {
|
| 79 |
+
announceReady();
|
| 80 |
+
return;
|
| 81 |
+
}
|
| 82 |
+
if (msg.type === 'avatar:event') {
|
| 83 |
+
emit(msg.event, msg.data);
|
| 84 |
+
return;
|
| 85 |
+
}
|
| 86 |
+
if (msg.type !== 'avatar:reply') return;
|
| 87 |
+
|
| 88 |
+
const slot = pending.get(msg.id);
|
| 89 |
+
if (!slot) return;
|
| 90 |
+
pending.delete(msg.id);
|
| 91 |
+
clearTimeout(slot.timer);
|
| 92 |
+
if (msg.ok) slot.resolve(msg.value);
|
| 93 |
+
else slot.reject(new Error(msg.error || `stage frame failed: ${msg.id}`));
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
/** One request, one reply, correlated by id. This is the whole transport. */
|
| 97 |
+
async function call(type, extra) {
|
| 98 |
+
await frameReady;
|
| 99 |
+
seq += 1;
|
| 100 |
+
const id = `m${seq}`;
|
| 101 |
+
return new Promise((resolve, reject) => {
|
| 102 |
+
const timer = setTimeout(() => {
|
| 103 |
+
pending.delete(id);
|
| 104 |
+
reject(new Error(`stage frame did not answer ${type} within ${REPLY_TIMEOUT_MS} ms`));
|
| 105 |
+
}, REPLY_TIMEOUT_MS);
|
| 106 |
+
pending.set(id, { resolve, reject, timer });
|
| 107 |
+
frame.contentWindow.postMessage({ ...extra, type, id }, '*');
|
| 108 |
+
});
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// Byte-for-byte the same six names as the inline transport's stagePort. Only the
|
| 112 |
+
// bodies differ, and every body is one round trip.
|
| 113 |
+
const stagePort = {
|
| 114 |
+
mount: (vrmUrl) => call('avatar:mount', { vrmUrl: vrmUrl || props.vrmUrl }),
|
| 115 |
+
speak: (payload) => call('avatar:speak', { payload }),
|
| 116 |
+
replayCached: () => call('avatar:replay', {}),
|
| 117 |
+
setThinking: (value) => call('avatar:setThinking', { value }),
|
| 118 |
+
setListening: (value) => call('avatar:setListening', { value }),
|
| 119 |
+
getDebug: () => call('avatar:debug', {}),
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
const turnLoop = createTurnLoop({ stagePort, emit, getServer: () => server });
|
| 123 |
+
|
| 124 |
+
const installed = installFacade({
|
| 125 |
+
transport: 'iframe',
|
| 126 |
+
stagePort,
|
| 127 |
+
turnLoop,
|
| 128 |
+
onEmit: (name, data) => {
|
| 129 |
+
if (typeof trigger === 'function') trigger(name, data);
|
| 130 |
+
},
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
+
emit.connect(installed.emit);
|
| 134 |
+
await installed.avatar.mount(props.vrmUrl);
|
| 135 |
+
return installed.avatar;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/**
|
| 139 |
+
* What the host's watch('value', ...) calls. Identical to the inline transport's:
|
| 140 |
+
* routing a directive is not turn behaviour, it is one line of delegation.
|
| 141 |
+
*/
|
| 142 |
+
export function onDirective(value) {
|
| 143 |
+
if (value === null || value === undefined || value === '') return null;
|
| 144 |
+
let directive = value;
|
| 145 |
+
if (typeof value === 'string') {
|
| 146 |
+
try {
|
| 147 |
+
directive = JSON.parse(value);
|
| 148 |
+
} catch {
|
| 149 |
+
return null;
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
if (!directive || typeof directive !== 'object') return null;
|
| 153 |
+
if (!window.Avatar) throw new Error('onDirective called before boot()');
|
| 154 |
+
return window.Avatar.speak(directive);
|
| 155 |
+
}
|
avatar/avatar.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// avatar/avatar.js
|
| 2 |
+
//
|
| 3 |
+
// THE INLINE TRANSPORT: the renderer lives in the same document as the host component.
|
| 4 |
+
//
|
| 5 |
+
// Its ONLY job is to build a six-method stagePort and hand it to the shared
|
| 6 |
+
// installFacade. It implements no turn behaviour and it never assigns window.Avatar.
|
| 7 |
+
// tests/test_transport_seam.py fails if either rule is broken here.
|
| 8 |
+
|
| 9 |
+
import { getLastDecoded, playBuffer } from './audio-queue.js';
|
| 10 |
+
import { installFacade } from './facade.js';
|
| 11 |
+
import { makePlayer } from './lipsync.js';
|
| 12 |
+
import { createTurnLoop } from './turn-loop.js';
|
| 13 |
+
import { mountStage } from './vrm-stage.js';
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* A bus that exists before the real one does.
|
| 17 |
+
*
|
| 18 |
+
* mountStage() needs an emit callback, but the facade's event bus is only created
|
| 19 |
+
* once installFacade() runs - which is after the stage is mounted. Anything emitted
|
| 20 |
+
* during mount (notably the 'error' raised when a VRM has no expressionManager, the
|
| 21 |
+
* exact symptom of a double three.js instance) would otherwise be dropped on the
|
| 22 |
+
* floor. This buffers until connect() and then replays in order.
|
| 23 |
+
*/
|
| 24 |
+
function deferredBus() {
|
| 25 |
+
let sink = null;
|
| 26 |
+
const queued = [];
|
| 27 |
+
const emit = (name, data) => {
|
| 28 |
+
if (sink) sink(name, data);
|
| 29 |
+
else queued.push([name, data]);
|
| 30 |
+
};
|
| 31 |
+
emit.connect = (real) => {
|
| 32 |
+
sink = real;
|
| 33 |
+
while (queued.length > 0) {
|
| 34 |
+
const [name, data] = queued.shift();
|
| 35 |
+
sink(name, data);
|
| 36 |
+
}
|
| 37 |
+
};
|
| 38 |
+
return emit;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* @param {HTMLElement} element the host-provided mount point
|
| 43 |
+
* @param {object} props at minimum { vrmUrl }
|
| 44 |
+
* @param {Function} [trigger] host event sink, so the backend also sees avatar events
|
| 45 |
+
* @param {object} [server] host bridge, handed to the turn loop as a getter
|
| 46 |
+
*/
|
| 47 |
+
export async function boot(element, props = {}, trigger = null, server = null) {
|
| 48 |
+
// Re-entry guard. key="vrm-stage" should stop the host re-running js_on_load at all,
|
| 49 |
+
// but if it ever does, returning the live object without remounting is what holds
|
| 50 |
+
// mountCount at 1 across 20 interactions (AVTR-01).
|
| 51 |
+
if (window.Avatar && window.Avatar.__debug && window.Avatar.__debug.ready) {
|
| 52 |
+
return window.Avatar;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const canvas = element.querySelector('#vrm-canvas');
|
| 56 |
+
if (!canvas) throw new Error('boot: no #vrm-canvas inside the mounted element');
|
| 57 |
+
|
| 58 |
+
const AudioCtor = window.AudioContext || window.webkitAudioContext;
|
| 59 |
+
const audioCtx = new AudioCtor();
|
| 60 |
+
const emit = deferredBus();
|
| 61 |
+
|
| 62 |
+
let stage = null;
|
| 63 |
+
let player = null;
|
| 64 |
+
let mounting = null;
|
| 65 |
+
let cached = null;
|
| 66 |
+
|
| 67 |
+
// Memoised: the stage is mounted exactly once no matter how many callers ask.
|
| 68 |
+
function ensureStage(vrmUrl) {
|
| 69 |
+
if (!mounting) {
|
| 70 |
+
mounting = (async () => {
|
| 71 |
+
stage = await mountStage(canvas, vrmUrl || props.vrmUrl, emit);
|
| 72 |
+
player = makePlayer(stage);
|
| 73 |
+
stage.setOnTick((dt) => player.tick(dt));
|
| 74 |
+
return stage;
|
| 75 |
+
})();
|
| 76 |
+
}
|
| 77 |
+
return mounting;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
await ensureStage(props.vrmUrl);
|
| 81 |
+
|
| 82 |
+
// The stagePort is the ONLY thing that differs between transports. Here every
|
| 83 |
+
// method is a direct call; in the iframe transport every method is a round trip.
|
| 84 |
+
const stagePort = {
|
| 85 |
+
mount: (vrmUrl) => ensureStage(vrmUrl),
|
| 86 |
+
|
| 87 |
+
async speak(payload = {}) {
|
| 88 |
+
await ensureStage();
|
| 89 |
+
cached = payload;
|
| 90 |
+
return playBuffer(audioCtx, payload.audioUrl, emit, (when) =>
|
| 91 |
+
player.start(payload.timeline, audioCtx, when)
|
| 92 |
+
);
|
| 93 |
+
},
|
| 94 |
+
|
| 95 |
+
async replayCached() {
|
| 96 |
+
// The decoded buffer, not the URL: zero network requests, which is what
|
| 97 |
+
// plan 01-09's test_replay asserts with a browser request listener.
|
| 98 |
+
const buffer = getLastDecoded();
|
| 99 |
+
if (!buffer) throw new Error('nothing cached to re-play');
|
| 100 |
+
return playBuffer(audioCtx, buffer, emit, (when) =>
|
| 101 |
+
player.start(cached && cached.timeline, audioCtx, when)
|
| 102 |
+
);
|
| 103 |
+
},
|
| 104 |
+
|
| 105 |
+
setThinking: (value) => (stage ? stage.setThinking(value) : undefined),
|
| 106 |
+
setListening: (value) => (stage ? stage.setListening(value) : undefined),
|
| 107 |
+
getDebug: async () => (stage ? stage.getDebug() : {}),
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
const turnLoop = createTurnLoop({ stagePort, emit, getServer: () => server });
|
| 111 |
+
|
| 112 |
+
const installed = installFacade({
|
| 113 |
+
transport: 'inline',
|
| 114 |
+
stagePort,
|
| 115 |
+
turnLoop,
|
| 116 |
+
// The one host-aware line in the file: mirror avatar events onto the host's own
|
| 117 |
+
// event system so backend listeners see them too.
|
| 118 |
+
onEmit: (name, data) => {
|
| 119 |
+
if (typeof trigger === 'function') trigger(name, data);
|
| 120 |
+
},
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
emit.connect(installed.emit);
|
| 124 |
+
await installed.avatar.mount(props.vrmUrl);
|
| 125 |
+
return installed.avatar;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/**
|
| 129 |
+
* What the host's watch('value', ...) calls. Tolerates null, '' and already-parsed
|
| 130 |
+
* objects, because a component's initial value is empty and must not throw.
|
| 131 |
+
*/
|
| 132 |
+
export function onDirective(value) {
|
| 133 |
+
if (value === null || value === undefined || value === '') return null;
|
| 134 |
+
let directive = value;
|
| 135 |
+
if (typeof value === 'string') {
|
| 136 |
+
try {
|
| 137 |
+
directive = JSON.parse(value);
|
| 138 |
+
} catch {
|
| 139 |
+
return null;
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
if (!directive || typeof directive !== 'object') return null;
|
| 143 |
+
if (!window.Avatar) throw new Error('onDirective called before boot()');
|
| 144 |
+
return window.Avatar.speak(directive);
|
| 145 |
+
}
|
avatar/stage.html
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>Avatar stage</title>
|
| 7 |
+
<style>
|
| 8 |
+
html,
|
| 9 |
+
body {
|
| 10 |
+
margin: 0;
|
| 11 |
+
height: 100%;
|
| 12 |
+
overflow: hidden;
|
| 13 |
+
background: transparent;
|
| 14 |
+
}
|
| 15 |
+
#vrm-canvas {
|
| 16 |
+
width: 100%;
|
| 17 |
+
height: 100%;
|
| 18 |
+
display: block;
|
| 19 |
+
}
|
| 20 |
+
</style>
|
| 21 |
+
</head>
|
| 22 |
+
<body>
|
| 23 |
+
<canvas id="vrm-canvas"></canvas>
|
| 24 |
+
|
| 25 |
+
<!--
|
| 26 |
+
This page has two jobs and both matter.
|
| 27 |
+
|
| 28 |
+
1. It is the IFRAME FALLBACK TARGET. avatar/avatar-iframe.js drives it entirely
|
| 29 |
+
through the postMessage protocol implemented at the bottom of this file.
|
| 30 |
+
2. It is the LOCAL DEBUG HARNESS. Open it directly - no Python, no host framework
|
| 31 |
+
- and the avatar renders, blinks, breathes and sways. With ?demo=1 it also
|
| 32 |
+
lip-syncs a canned utterance. If this page works and the deployed inline
|
| 33 |
+
component does not, the problem is the host, not three.js.
|
| 34 |
+
|
| 35 |
+
Query parameters: ?vrm=<url> (defaults to ./assets/tutor.vrm), ?demo=1
|
| 36 |
+
-->
|
| 37 |
+
<script type="module">
|
| 38 |
+
import { playBuffer, getLastDecoded } from './audio-queue.js';
|
| 39 |
+
import { makePlayer } from './lipsync.js';
|
| 40 |
+
import { mountStage } from './vrm-stage.js';
|
| 41 |
+
|
| 42 |
+
// PLACEHOLDER shaped like a real VOICEVOX timeline. These durations are
|
| 43 |
+
// hand-authored, NOT measured. Plan 01-06 replaces this with a timeline
|
| 44 |
+
// generated from a real AudioQuery. こんにちは = k o N n i ch i w a, chosen
|
| 45 |
+
// because the N gives a 'closed' viseme sandwiched between two vowels.
|
| 46 |
+
const DEMO_TIMELINE = [
|
| 47 |
+
{ t: 0.0, dur: 0.1, viseme: 'closed', weight: 0.0 }, // prePhonemeLength
|
| 48 |
+
{ t: 0.1, dur: 0.064, viseme: 'closed', weight: 0.0 }, // k
|
| 49 |
+
{ t: 0.164, dur: 0.128, viseme: 'oh', weight: 1.0 }, // o
|
| 50 |
+
{ t: 0.292, dur: 0.139, viseme: 'closed', weight: 0.0 }, // N
|
| 51 |
+
{ t: 0.431, dur: 0.053, viseme: 'closed', weight: 0.0 }, // n
|
| 52 |
+
{ t: 0.484, dur: 0.117, viseme: 'ih', weight: 1.0 }, // i
|
| 53 |
+
{ t: 0.601, dur: 0.075, viseme: 'closed', weight: 0.0 }, // ch
|
| 54 |
+
{ t: 0.676, dur: 0.107, viseme: 'ih', weight: 1.0 }, // i
|
| 55 |
+
{ t: 0.783, dur: 0.043, viseme: 'closed', weight: 0.0 }, // w
|
| 56 |
+
{ t: 0.826, dur: 0.203, viseme: 'aa', weight: 1.0 }, // a
|
| 57 |
+
{ t: 1.029, dur: 0.1, viseme: 'closed', weight: 0.0 }, // postPhonemeLength
|
| 58 |
+
];
|
| 59 |
+
const DEMO_AUDIO = './assets/demo-konnichiwa.wav';
|
| 60 |
+
|
| 61 |
+
const params = new URLSearchParams(location.search);
|
| 62 |
+
const vrmUrl = params.get('vrm') || './assets/tutor.vrm';
|
| 63 |
+
const wantDemo = params.get('demo') === '1';
|
| 64 |
+
|
| 65 |
+
const host = window.parent !== window ? window.parent : null;
|
| 66 |
+
const post = (msg) => host && host.postMessage(msg, '*');
|
| 67 |
+
const emit = (event, data) => post({ type: 'avatar:event', event, data });
|
| 68 |
+
|
| 69 |
+
const canvas = document.getElementById('vrm-canvas');
|
| 70 |
+
let audioCtx = null;
|
| 71 |
+
const ctx = () => {
|
| 72 |
+
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
| 73 |
+
return audioCtx;
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
let stage = null;
|
| 77 |
+
let player = null;
|
| 78 |
+
let mounting = null;
|
| 79 |
+
let cached = null;
|
| 80 |
+
|
| 81 |
+
function ensureStage(url) {
|
| 82 |
+
if (!mounting) {
|
| 83 |
+
mounting = (async () => {
|
| 84 |
+
stage = await mountStage(canvas, url || vrmUrl, emit);
|
| 85 |
+
player = makePlayer(stage);
|
| 86 |
+
// One tick fn drives the viseme player and mirrors the stage's debug
|
| 87 |
+
// object onto window.__stageDebug, so a browser test can read numbers
|
| 88 |
+
// instead of screenshotting a canvas.
|
| 89 |
+
stage.setOnTick((dt) => {
|
| 90 |
+
player.tick(dt);
|
| 91 |
+
window.__stageDebug = stage.getDebug();
|
| 92 |
+
});
|
| 93 |
+
window.__stageDebug = stage.getDebug();
|
| 94 |
+
return stage;
|
| 95 |
+
})();
|
| 96 |
+
}
|
| 97 |
+
return mounting;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
async function speak(payload) {
|
| 101 |
+
const directive = payload || {};
|
| 102 |
+
await ensureStage();
|
| 103 |
+
cached = directive;
|
| 104 |
+
return playBuffer(ctx(), directive.audioUrl, emit, (when) =>
|
| 105 |
+
player.start(directive.timeline, ctx(), when)
|
| 106 |
+
);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
async function replayCached() {
|
| 110 |
+
const buffer = getLastDecoded();
|
| 111 |
+
if (!buffer) throw new Error('nothing cached to re-play');
|
| 112 |
+
return playBuffer(ctx(), buffer, emit, (when) =>
|
| 113 |
+
player.start(cached && cached.timeline, ctx(), when)
|
| 114 |
+
);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
async function runDemo() {
|
| 118 |
+
await ensureStage();
|
| 119 |
+
const c = ctx();
|
| 120 |
+
if (c.state === 'suspended') {
|
| 121 |
+
try {
|
| 122 |
+
await c.resume();
|
| 123 |
+
} catch {
|
| 124 |
+
/* still gesture-gated; the pointerdown handler below retries */
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
return speak({ audioUrl: DEMO_AUDIO, timeline: DEMO_TIMELINE, subtitle: 'こんにちは' });
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// The postMessage protocol. parent -> frame requests, frame -> parent replies.
|
| 131 |
+
const HANDLERS = {
|
| 132 |
+
'avatar:mount': async (m) => {
|
| 133 |
+
await ensureStage(m.vrmUrl);
|
| 134 |
+
return stage.getDebug();
|
| 135 |
+
},
|
| 136 |
+
'avatar:speak': (m) => speak(m.payload),
|
| 137 |
+
'avatar:replay': () => replayCached(),
|
| 138 |
+
'avatar:setThinking': async (m) => {
|
| 139 |
+
await ensureStage();
|
| 140 |
+
stage.setThinking(m.value);
|
| 141 |
+
return !!m.value;
|
| 142 |
+
},
|
| 143 |
+
'avatar:setListening': async (m) => {
|
| 144 |
+
await ensureStage();
|
| 145 |
+
stage.setListening(m.value);
|
| 146 |
+
return !!m.value;
|
| 147 |
+
},
|
| 148 |
+
'avatar:debug': async () => (stage ? stage.getDebug() : {}),
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
+
window.addEventListener('message', async (ev) => {
|
| 152 |
+
const m = ev.data;
|
| 153 |
+
if (!m || typeof m.type !== 'string') return;
|
| 154 |
+
const handler = HANDLERS[m.type];
|
| 155 |
+
if (!handler) return;
|
| 156 |
+
try {
|
| 157 |
+
const value = await handler(m);
|
| 158 |
+
post({ type: 'avatar:reply', id: m.id, ok: true, value: value === undefined ? null : value });
|
| 159 |
+
} catch (err) {
|
| 160 |
+
post({
|
| 161 |
+
type: 'avatar:reply',
|
| 162 |
+
id: m.id,
|
| 163 |
+
ok: false,
|
| 164 |
+
error: String((err && err.message) || err),
|
| 165 |
+
});
|
| 166 |
+
}
|
| 167 |
+
});
|
| 168 |
+
|
| 169 |
+
// Mount eagerly so the VRM download starts immediately and so opening this file
|
| 170 |
+
// directly shows a living avatar with no driver at all.
|
| 171 |
+
ensureStage().catch((err) =>
|
| 172 |
+
emit('error', { message: String((err && err.message) || err), where: 'mount' })
|
| 173 |
+
);
|
| 174 |
+
|
| 175 |
+
if (wantDemo) {
|
| 176 |
+
runDemo().catch((err) =>
|
| 177 |
+
emit('error', { message: String((err && err.message) || err), where: 'demo' })
|
| 178 |
+
);
|
| 179 |
+
// Browsers that gate audio behind a gesture: one click and the demo runs.
|
| 180 |
+
document.addEventListener('pointerdown', () => {
|
| 181 |
+
if (ctx().state !== 'running') runDemo();
|
| 182 |
+
});
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
post({ type: 'avatar:frame-ready' });
|
| 186 |
+
</script>
|
| 187 |
+
</body>
|
| 188 |
+
</html>
|
avatar/vrm-stage.js
CHANGED
|
@@ -61,8 +61,9 @@ export async function mountStage(canvasEl, vrmUrl, emit = () => {}) {
|
|
| 61 |
// https://esm.sh/three@0.185.1/es2022/three.mjs, so the browser's module map
|
| 62 |
// guarantees a single instance. Dropping ?deps= gives two instances and the VRM
|
| 63 |
// silently degrades to a T-posed glTF with no humanoid and no expressions.
|
| 64 |
-
// No import map:
|
| 65 |
-
//
|
|
|
|
| 66 |
const [THREE, { GLTFLoader }, { VRMLoaderPlugin, VRMUtils }] = await Promise.all([
|
| 67 |
import(THREE_URL),
|
| 68 |
import(LOADER_URL),
|
|
|
|
| 61 |
// https://esm.sh/three@0.185.1/es2022/three.mjs, so the browser's module map
|
| 62 |
// guarantees a single instance. Dropping ?deps= gives two instances and the VRM
|
| 63 |
// silently degrades to a T-posed glTF with no humanoid and no expressions.
|
| 64 |
+
// No import map: the host page is an already-booted ES-module app, so module
|
| 65 |
+
// resolution has begun long before this file runs and a late import map throws
|
| 66 |
+
// "An import map is added after module script load was triggered."
|
| 67 |
const [THREE, { GLTFLoader }, { VRMLoaderPlugin, VRMUtils }] = await Promise.all([
|
| 68 |
import(THREE_URL),
|
| 69 |
import(LOADER_URL),
|
pyproject.toml
CHANGED
|
@@ -55,7 +55,10 @@ target-version = "py312"
|
|
| 55 |
# ruff 0.16+ lints and formats Python code blocks embedded in Markdown. Planning docs,
|
| 56 |
# research notes and design snippets are prose, not source: excluding *.md keeps the lint
|
| 57 |
# gate about shipped Python and stops doc snippets from failing `ruff format --check`.
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
[tool.ruff.lint]
|
| 61 |
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
|
|
| 55 |
# ruff 0.16+ lints and formats Python code blocks embedded in Markdown. Planning docs,
|
| 56 |
# research notes and design snippets are prose, not source: excluding *.md keeps the lint
|
| 57 |
# gate about shipped Python and stops doc snippets from failing `ruff format --check`.
|
| 58 |
+
# *.pyi: Gradio auto-generates a type stub beside any module that subclasses a
|
| 59 |
+
# component, with no opt-out. It is generated output we cannot edit, so it is not
|
| 60 |
+
# subject to our lint gate.
|
| 61 |
+
extend-exclude = ["avatar/vendor", "*.md", "*.pyi"]
|
| 62 |
|
| 63 |
[tool.ruff.lint]
|
| 64 |
select = ["E", "F", "I", "UP", "B", "SIM"]
|
src/japanese_avatar/ui/avatar_component.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The single gr.HTML component that owns the WebGL canvas.
|
| 2 |
+
|
| 3 |
+
One env var, read in one place, chooses which transport module ``js_on_load``
|
| 4 |
+
imports. Everything downstream of that import - the facade, the turn loop, the
|
| 5 |
+
renderer - is identical between the two, which is what makes the fallback a flag
|
| 6 |
+
flip rather than a re-plan.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
import gradio as gr
|
| 12 |
+
|
| 13 |
+
AVATAR_TRANSPORT = os.environ.get("AVATAR_TRANSPORT", "inline") # "inline" | "iframe"
|
| 14 |
+
VRM_URL = "/gradio_api/file=avatar/assets/tutor.vrm"
|
| 15 |
+
|
| 16 |
+
# No `head=`: the modules load via dynamic import() inside js_on_load, so there is no
|
| 17 |
+
# ordering constraint and no import map to be injected too late.
|
| 18 |
+
_INLINE_JS = """
|
| 19 |
+
const m = await import('/gradio_api/file=avatar/avatar.js');
|
| 20 |
+
await m.boot(element, props, trigger, server);
|
| 21 |
+
watch('value', () => m.onDirective(props.value));
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
_IFRAME_JS = """
|
| 25 |
+
const m = await import('/gradio_api/file=avatar/avatar-iframe.js');
|
| 26 |
+
await m.boot(element, props, trigger, server);
|
| 27 |
+
watch('value', () => m.onDirective(props.value));
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class VrmStage(gr.HTML):
|
| 32 |
+
"""The single gr.HTML component that owns the WebGL canvas.
|
| 33 |
+
|
| 34 |
+
Mounted with key="vrm-stage" so Gradio never remounts it across re-renders;
|
| 35 |
+
AVTR-01 requires mountCount to stay at 1 over 20 interactions.
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
def __init__(self, **kwargs):
|
| 39 |
+
super().__init__(
|
| 40 |
+
value="",
|
| 41 |
+
html_template='<div class="vrm-stage"><canvas id="vrm-canvas"></canvas></div>',
|
| 42 |
+
css_template=(
|
| 43 |
+
".vrm-stage{position:relative;width:100%;height:100%;min-height:480px}"
|
| 44 |
+
"#vrm-canvas{width:100%;height:100%;display:block}"
|
| 45 |
+
".vrm-stage iframe{width:100%;height:100%;border:0;display:block}"
|
| 46 |
+
),
|
| 47 |
+
js_on_load=_IFRAME_JS if AVATAR_TRANSPORT == "iframe" else _INLINE_JS,
|
| 48 |
+
props={"vrmUrl": VRM_URL, "transport": AVATAR_TRANSPORT},
|
| 49 |
+
container=False,
|
| 50 |
+
padding=False,
|
| 51 |
+
min_height=480,
|
| 52 |
+
key="vrm-stage",
|
| 53 |
+
preserved_by_key="value",
|
| 54 |
+
elem_id="vrm-stage",
|
| 55 |
+
**kwargs,
|
| 56 |
+
)
|