Spaces:
Running on Zero
Running on Zero
feat(02-07): furigana controls, level picker, persistence; every transcript line rendered from tokens
Browse files- blocks.py: READING_CONTROLS_HTML (#furigana-mode + #level-select) above the
transcript, transcript.css linked from TRANSCRIPT_HTML, honest Phase 2 intro
- avatar_component.py: the boot template imports transcript.js and hands it to
bindHost(avatar, document, transcript), so both transports render identical ruby
- host.js: learnerLine/avatarLine through the renderer, every turn rendered from
the turn event's tokens, jla.furigana / jla.level persisted through the two
guarded storageRead/storageWrite helpers, furigana.* seeded on __debug
- test_transport_seam.py: the three-argument bind, one transcript.js import,
every-line rendering, the storage guard and the seeded debug keys
- avatar/host.js +148 -7
- src/japanese_avatar/ui/avatar_component.py +4 -1
- src/japanese_avatar/ui/blocks.py +29 -3
- tests/test_transport_seam.py +49 -1
avatar/host.js
CHANGED
|
@@ -14,26 +14,78 @@
|
|
| 14 |
// #latency-text, #asr-tier-text) rather than to the host's wrapper elements, so a
|
| 15 |
// re-render of a wrapper cannot delete a line, and user-supplied text is always written
|
| 16 |
// through textContent, never innerHTML.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
/** Matches REARM_TAIL_MS in mic.js: the controls re-enable when the mic may re-arm. */
|
| 19 |
const REENABLE_AFTER_SPEECH_MS = 200;
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
/** Enter finishes a Japanese IME composition before it submits; this skips that Enter. */
|
| 22 |
function isComposing(event) {
|
| 23 |
return event.isComposing || event.keyCode === 229;
|
| 24 |
}
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
/**
|
| 27 |
* @param {object} avatar the facade window.Avatar
|
| 28 |
* @param {Document} [doc]
|
|
|
|
| 29 |
* @returns {boolean} whether the bindings were installed by this call
|
| 30 |
*/
|
| 31 |
-
export function bindHost(avatar, doc = document) {
|
| 32 |
if (!avatar || typeof avatar.on !== 'function') return false;
|
| 33 |
// boot() is re-entrant and returns the live object; the bindings must not double up.
|
| 34 |
if (doc.__avatarHostBound) return false;
|
| 35 |
doc.__avatarHostBound = true;
|
| 36 |
|
|
|
|
| 37 |
const byId = (id) => doc.getElementById(id);
|
| 38 |
const text = (id, value) => {
|
| 39 |
const el = byId(id);
|
|
@@ -49,6 +101,22 @@ export function bindHost(avatar, doc = document) {
|
|
| 49 |
replay: byId('replay-button'),
|
| 50 |
slower: byId('slower-button'),
|
| 51 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
let spoken = false; // whether anything has been said yet, for replay/slower
|
| 54 |
let busy = false;
|
|
@@ -71,8 +139,9 @@ export function bindHost(avatar, doc = document) {
|
|
| 71 |
applyEnabled();
|
| 72 |
}
|
| 73 |
|
|
|
|
| 74 |
function transcriptLine(who, value) {
|
| 75 |
-
const el =
|
| 76 |
if (!el) return;
|
| 77 |
const line = doc.createElement('div');
|
| 78 |
line.className = `turn turn-${who}`;
|
|
@@ -87,6 +156,36 @@ export function bindHost(avatar, doc = document) {
|
|
| 87 |
el.scrollTop = el.scrollHeight;
|
| 88 |
}
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
function renderLatency({ lastTurnMs, timings }) {
|
| 91 |
const t = timings || {};
|
| 92 |
const ms = (key) => (typeof t[key] === 'number' ? Math.round(t[key]) : '—');
|
|
@@ -121,7 +220,7 @@ export function bindHost(avatar, doc = document) {
|
|
| 121 |
return;
|
| 122 |
}
|
| 123 |
// Echo before the round trip, so the visitor sees their words the instant they send.
|
| 124 |
-
|
| 125 |
if (el) {
|
| 126 |
el.value = '';
|
| 127 |
// The host's textbox mirrors its value from input events; a bare .value write
|
|
@@ -131,19 +230,60 @@ export function bindHost(avatar, doc = document) {
|
|
| 131 |
dispatch(value);
|
| 132 |
}
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
// ------------------------------------------------------------------- avatar -> page
|
| 135 |
avatar.on('listening', ({ active } = {}) => status(active ? 'listening…' : 'transcribing…'));
|
| 136 |
avatar.on('transcript', ({ text: heard } = {}) => {
|
| 137 |
if (!heard) return;
|
| 138 |
-
|
| 139 |
dispatch(heard);
|
| 140 |
});
|
| 141 |
avatar.on('turn-start', () => {
|
| 142 |
setBusy(true);
|
| 143 |
status('thinking…');
|
| 144 |
});
|
| 145 |
-
avatar
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
| 147 |
spoken = true;
|
| 148 |
});
|
| 149 |
avatar.on('speech-start', () => {
|
|
@@ -168,7 +308,8 @@ export function bindHost(avatar, doc = document) {
|
|
| 168 |
// gesture's own call stack - the only place a gesture-gated browser (iOS Safari;
|
| 169 |
// Chromium in a cross-origin embed) lets an AudioContext resume. The turn loop makes
|
| 170 |
// the same call at its entry points; this copy covers the gestures that never reach
|
| 171 |
-
// the loop (Send with an empty box, Enter mid-composition) and the ones that do.
|
|
|
|
| 172 |
if (controls.send) {
|
| 173 |
controls.send.addEventListener('click', () => {
|
| 174 |
avatar.unlockAudio();
|
|
|
|
| 14 |
// #latency-text, #asr-tier-text) rather than to the host's wrapper elements, so a
|
| 15 |
// re-render of a wrapper cannot delete a line, and user-supplied text is always written
|
| 16 |
// through textContent, never innerHTML.
|
| 17 |
+
//
|
| 18 |
+
// Plan 02-07: the transcript is rendered by the DOM-only transcript module the boot
|
| 19 |
+
// template imports and hands over as the third argument. EVERY line goes through it:
|
| 20 |
+
// the avatar's lines from the tokens the 'turn' event carries, the learner's lines echoed
|
| 21 |
+
// at once as plain text and upgraded to ruby when analyze() answers (D-01, D-03). The two
|
| 22 |
+
// reading controls (#furigana-mode, #level-select) drive its mode and level, and both
|
| 23 |
+
// preferences persist in the browser under jla.furigana / jla.level (D-14), read and
|
| 24 |
+
// written only through storageRead / storageWrite, which never throw - Safari inside a
|
| 25 |
+
// cross-origin iframe throws on the access itself (research Pitfall 9), and the page
|
| 26 |
+
// then runs in memory with furigana.storage reporting 'unavailable'.
|
| 27 |
|
| 28 |
/** Matches REARM_TAIL_MS in mic.js: the controls re-enable when the mic may re-arm. */
|
| 29 |
const REENABLE_AFTER_SPEECH_MS = 200;
|
| 30 |
|
| 31 |
+
/** The persisted reading preferences (D-14). Phase 4 adopts the same keys into the account. */
|
| 32 |
+
const STORAGE_MODE_KEY = 'jla.furigana';
|
| 33 |
+
const STORAGE_LEVEL_KEY = 'jla.level';
|
| 34 |
+
|
| 35 |
+
/** Seeded on __debug even without a transcript module, so the key set is timing-independent. */
|
| 36 |
+
const FURIGANA_DEBUG_SEED = {
|
| 37 |
+
mode: 'always',
|
| 38 |
+
level: 'N5',
|
| 39 |
+
storage: 'unknown',
|
| 40 |
+
lines: 0,
|
| 41 |
+
rtTotal: 0,
|
| 42 |
+
lastLineId: null,
|
| 43 |
+
lastLineRt: 0,
|
| 44 |
+
lastLineKanjiRuns: 0,
|
| 45 |
+
lastLineRtHeightPx: 0,
|
| 46 |
+
lastLineHeightPx: 0,
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
/** Enter finishes a Japanese IME composition before it submits; this skips that Enter. */
|
| 50 |
function isComposing(event) {
|
| 51 |
return event.isComposing || event.keyCode === 229;
|
| 52 |
}
|
| 53 |
|
| 54 |
+
/**
|
| 55 |
+
* Read one persisted preference. Returns { ok, value }: ok is false when the storage
|
| 56 |
+
* itself is unreachable (the access throws), value is null when the key is unset.
|
| 57 |
+
*/
|
| 58 |
+
function storageRead(win, key) {
|
| 59 |
+
try {
|
| 60 |
+
return { ok: true, value: win.localStorage.getItem(key) };
|
| 61 |
+
} catch {
|
| 62 |
+
return { ok: false, value: null };
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/** Write one persisted preference. Returns whether the storage accepted it. */
|
| 67 |
+
function storageWrite(win, key, value) {
|
| 68 |
+
try {
|
| 69 |
+
win.localStorage.setItem(key, String(value));
|
| 70 |
+
return true;
|
| 71 |
+
} catch {
|
| 72 |
+
return false;
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
/**
|
| 77 |
* @param {object} avatar the facade window.Avatar
|
| 78 |
* @param {Document} [doc]
|
| 79 |
+
* @param {object} [transcriptModule] the transcript renderer's exports; null renders plain lines
|
| 80 |
* @returns {boolean} whether the bindings were installed by this call
|
| 81 |
*/
|
| 82 |
+
export function bindHost(avatar, doc = document, transcriptModule = null) {
|
| 83 |
if (!avatar || typeof avatar.on !== 'function') return false;
|
| 84 |
// boot() is re-entrant and returns the live object; the bindings must not double up.
|
| 85 |
if (doc.__avatarHostBound) return false;
|
| 86 |
doc.__avatarHostBound = true;
|
| 87 |
|
| 88 |
+
const win = doc.defaultView || window;
|
| 89 |
const byId = (id) => doc.getElementById(id);
|
| 90 |
const text = (id, value) => {
|
| 91 |
const el = byId(id);
|
|
|
|
| 101 |
replay: byId('replay-button'),
|
| 102 |
slower: byId('slower-button'),
|
| 103 |
};
|
| 104 |
+
const reading = {
|
| 105 |
+
mode: byId('furigana-mode'),
|
| 106 |
+
level: byId('level-select'),
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
// The transcript renderer, when the boot template handed it over and the page has the
|
| 110 |
+
// element. Without it (a harness that loads host.js alone) lines fall back to plain text.
|
| 111 |
+
const transcriptEl = byId('transcript-text');
|
| 112 |
+
const transcript =
|
| 113 |
+
transcriptModule && typeof transcriptModule.createTranscript === 'function' && transcriptEl
|
| 114 |
+
? transcriptModule.createTranscript(transcriptEl, { doc })
|
| 115 |
+
: null;
|
| 116 |
+
// The SAME object the renderer writes to, so getDebug() reads live numbers; the facade
|
| 117 |
+
// merges the turn loop's and the stage's keys over the top and leaves this one intact.
|
| 118 |
+
const furigana = transcript ? transcript.debug.furigana : { ...FURIGANA_DEBUG_SEED };
|
| 119 |
+
if (avatar.__debug) avatar.__debug.furigana = furigana;
|
| 120 |
|
| 121 |
let spoken = false; // whether anything has been said yet, for replay/slower
|
| 122 |
let busy = false;
|
|
|
|
| 139 |
applyEnabled();
|
| 140 |
}
|
| 141 |
|
| 142 |
+
/** The plain-text fallback used only when no transcript module was handed over. */
|
| 143 |
function transcriptLine(who, value) {
|
| 144 |
+
const el = transcriptEl;
|
| 145 |
if (!el) return;
|
| 146 |
const line = doc.createElement('div');
|
| 147 |
line.className = `turn turn-${who}`;
|
|
|
|
| 156 |
el.scrollTop = el.scrollHeight;
|
| 157 |
}
|
| 158 |
|
| 159 |
+
/**
|
| 160 |
+
* The learner's line (D-03): echoed synchronously as plain text - the visitor sees their
|
| 161 |
+
* words the instant they send, before any round trip - then upgraded to ruby when the
|
| 162 |
+
* analyze() answer arrives. Fire and forget: it never delays the turn's dispatch, and a
|
| 163 |
+
* failed analysis leaves the plain line in place.
|
| 164 |
+
*/
|
| 165 |
+
function learnerLine(value) {
|
| 166 |
+
if (!transcript) {
|
| 167 |
+
transcriptLine('you', value);
|
| 168 |
+
return;
|
| 169 |
+
}
|
| 170 |
+
const { lineId } = transcript.addLine({ who: 'you', text: value });
|
| 171 |
+
avatar
|
| 172 |
+
.analyze(value)
|
| 173 |
+
.then((result) => transcript.setTokens(lineId, result.tokens))
|
| 174 |
+
.catch(() => {
|
| 175 |
+
/* the plain echo stands; the turn loop has already reported the error */
|
| 176 |
+
});
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
/** The avatar's line, rendered from the tokens the directive carried (no second round trip). */
|
| 180 |
+
function avatarLine(who, value, tokens) {
|
| 181 |
+
if (!transcript) {
|
| 182 |
+
transcriptLine(who, value);
|
| 183 |
+
return;
|
| 184 |
+
}
|
| 185 |
+
const { lineId } = transcript.addLine({ who, text: value });
|
| 186 |
+
transcript.setTokens(lineId, Array.isArray(tokens) ? tokens : []);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
function renderLatency({ lastTurnMs, timings }) {
|
| 190 |
const t = timings || {};
|
| 191 |
const ms = (key) => (typeof t[key] === 'number' ? Math.round(t[key]) : '—');
|
|
|
|
| 220 |
return;
|
| 221 |
}
|
| 222 |
// Echo before the round trip, so the visitor sees their words the instant they send.
|
| 223 |
+
learnerLine(value);
|
| 224 |
if (el) {
|
| 225 |
el.value = '';
|
| 226 |
// The host's textbox mirrors its value from input events; a bare .value write
|
|
|
|
| 230 |
dispatch(value);
|
| 231 |
}
|
| 232 |
|
| 233 |
+
// ------------------------------------------------------------ reading preferences (D-14)
|
| 234 |
+
//
|
| 235 |
+
// Read at bind, validated against the renderer's own lists (junk is ignored), applied to
|
| 236 |
+
// the selects and the renderer, then written back so a first visit persists its
|
| 237 |
+
// defaults and the write path is exercised - which is what makes storage 'ok' mean
|
| 238 |
+
// "read AND write work". A change on either select re-renders every line client-side.
|
| 239 |
+
if (transcript) {
|
| 240 |
+
const modes = transcriptModule.FURIGANA_MODES || [];
|
| 241 |
+
const levels = transcriptModule.LEVELS || [];
|
| 242 |
+
const savedMode = storageRead(win, STORAGE_MODE_KEY);
|
| 243 |
+
const savedLevel = storageRead(win, STORAGE_LEVEL_KEY);
|
| 244 |
+
const mode = modes.includes(savedMode.value) ? savedMode.value : transcript.getMode();
|
| 245 |
+
const level = levels.includes(savedLevel.value) ? savedLevel.value : transcript.getLevel();
|
| 246 |
+
transcript.setMode(mode);
|
| 247 |
+
transcript.setLevel(level);
|
| 248 |
+
if (reading.mode) reading.mode.value = mode;
|
| 249 |
+
if (reading.level) reading.level.value = level;
|
| 250 |
+
const wrote =
|
| 251 |
+
storageWrite(win, STORAGE_MODE_KEY, mode) && storageWrite(win, STORAGE_LEVEL_KEY, level);
|
| 252 |
+
furigana.storage = savedMode.ok && savedLevel.ok && wrote ? 'ok' : 'unavailable';
|
| 253 |
+
|
| 254 |
+
if (reading.mode) {
|
| 255 |
+
reading.mode.addEventListener('change', () => {
|
| 256 |
+
const value = transcript.setMode(reading.mode.value);
|
| 257 |
+
reading.mode.value = value;
|
| 258 |
+
if (!storageWrite(win, STORAGE_MODE_KEY, value)) furigana.storage = 'unavailable';
|
| 259 |
+
});
|
| 260 |
+
}
|
| 261 |
+
if (reading.level) {
|
| 262 |
+
reading.level.addEventListener('change', () => {
|
| 263 |
+
const value = transcript.setLevel(reading.level.value);
|
| 264 |
+
reading.level.value = value;
|
| 265 |
+
if (!storageWrite(win, STORAGE_LEVEL_KEY, value)) furigana.storage = 'unavailable';
|
| 266 |
+
});
|
| 267 |
+
}
|
| 268 |
+
transcript.bindPointer(); // plan 02-08 fills this with the lookup popover's tap handling
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
// ------------------------------------------------------------------- avatar -> page
|
| 272 |
avatar.on('listening', ({ active } = {}) => status(active ? 'listening…' : 'transcribing…'));
|
| 273 |
avatar.on('transcript', ({ text: heard } = {}) => {
|
| 274 |
if (!heard) return;
|
| 275 |
+
learnerLine(heard);
|
| 276 |
dispatch(heard);
|
| 277 |
});
|
| 278 |
avatar.on('turn-start', () => {
|
| 279 |
setBusy(true);
|
| 280 |
status('thinking…');
|
| 281 |
});
|
| 282 |
+
// EVERY avatar line is rendered from the turn event's tokens (plan 02-07). Phase 1 skipped
|
| 283 |
+
// the echo of a typed line because it repeated the learner; with furigana the avatar's
|
| 284 |
+
// reading of the same words is the point.
|
| 285 |
+
avatar.on('turn', ({ subtitle, speed, tokens } = {}) => {
|
| 286 |
+
if (subtitle) avatarLine(speed < 1 ? 'slower' : 'avatar', subtitle, tokens);
|
| 287 |
spoken = true;
|
| 288 |
});
|
| 289 |
avatar.on('speech-start', () => {
|
|
|
|
| 308 |
// gesture's own call stack - the only place a gesture-gated browser (iOS Safari;
|
| 309 |
// Chromium in a cross-origin embed) lets an AudioContext resume. The turn loop makes
|
| 310 |
// the same call at its entry points; this copy covers the gestures that never reach
|
| 311 |
+
// the loop (Send with an empty box, Enter mid-composition) and the ones that do. The
|
| 312 |
+
// two reading selects above are deliberately NOT in this set: no audio path starts there.
|
| 313 |
if (controls.send) {
|
| 314 |
controls.send.addEventListener('click', () => {
|
| 315 |
avatar.unlockAudio();
|
src/japanese_avatar/ui/avatar_component.py
CHANGED
|
@@ -62,7 +62,10 @@ _BOOT_JS = """
|
|
| 62 |
// The host glue: binds the page's controls to the facade and renders what it
|
| 63 |
// reports. Loaded here, after boot, from the SAME template for both transports.
|
| 64 |
const host = await import('/gradio_api/file=avatar/host.js');
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
| 66 |
watch('value', () => m.onDirective(props.value));
|
| 67 |
}})().catch((err) => {{
|
| 68 |
console.error('avatar boot failed:', err);
|
|
|
|
| 62 |
// The host glue: binds the page's controls to the facade and renders what it
|
| 63 |
// reports. Loaded here, after boot, from the SAME template for both transports.
|
| 64 |
const host = await import('/gradio_api/file=avatar/host.js');
|
| 65 |
+
// transcript.js is DOM rendering handed to the host glue; loaded from this one
|
| 66 |
+
// template so both transports render identical ruby (plan 02-07).
|
| 67 |
+
const transcript = await import('/gradio_api/file=avatar/transcript.js');
|
| 68 |
+
host.bindHost(avatar, document, transcript);
|
| 69 |
watch('value', () => m.onDirective(props.value));
|
| 70 |
}})().catch((err) => {{
|
| 71 |
console.error('avatar boot failed:', err);
|
src/japanese_avatar/ui/blocks.py
CHANGED
|
@@ -343,15 +343,40 @@ def warm_language_on_load() -> None:
|
|
| 343 |
# public portfolio Space; an avatar that echoes must say it echoes.
|
| 344 |
INTRO_HTML = (
|
| 345 |
'<div id="intro" class="intro">'
|
| 346 |
-
"<strong>Phase
|
|
|
|
| 347 |
"Type Japanese and press Enter, or hold the microphone button and speak. Everything you hear "
|
| 348 |
"is synthesised on the CPU with mora-timed lip-sync; speech recognition runs in your browser."
|
| 349 |
"</div>"
|
| 350 |
)
|
| 351 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
# Server-rendered so the first paint already carries it; the inner ids are what the host script
|
| 353 |
-
# writes to, and the wrappers are Gradio's own elements that tests select by elem_id.
|
| 354 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
LATENCY_HTML = '<div id="latency-text" class="latency">dispatch→speech: —</div>'
|
| 356 |
ASR_BADGE_HTML = (
|
| 357 |
'<div id="asr-tier-text" class="asr-badge">ASR: loads in your browser on the first push</div>'
|
|
@@ -425,6 +450,7 @@ def build_blocks() -> gr.Blocks:
|
|
| 425 |
with gr.Column(scale=2, min_width=280):
|
| 426 |
StatusLine()
|
| 427 |
gr.HTML(value=INTRO_HTML, elem_id="intro-html")
|
|
|
|
| 428 |
gr.HTML(value=TRANSCRIPT_HTML, elem_id="transcript")
|
| 429 |
with gr.Row():
|
| 430 |
gr.Button("Hold to talk", elem_id="ptt-button", variant="secondary")
|
|
|
|
| 343 |
# public portfolio Space; an avatar that echoes must say it echoes.
|
| 344 |
INTRO_HTML = (
|
| 345 |
'<div id="intro" class="intro">'
|
| 346 |
+
"<strong>Phase 2: the avatar repeats what you say, now with furigana, word lookup and "
|
| 347 |
+
"translation. Tutoring arrives in Phase 3.</strong> "
|
| 348 |
"Type Japanese and press Enter, or hold the microphone button and speak. Everything you hear "
|
| 349 |
"is synthesised on the CPU with mora-timed lip-sync; speech recognition runs in your browser."
|
| 350 |
"</div>"
|
| 351 |
)
|
| 352 |
|
| 353 |
+
# Plan 02-07. The reading controls (D-02 / D-14): a three-way furigana mode and an N5-N2 level
|
| 354 |
+
# picker. Project-owned <select> elements inside a gr.HTML value rather than gr.Radio /
|
| 355 |
+
# gr.Dropdown, for the Phase 1 reason: avatar/host.js binds by element id and never depends on
|
| 356 |
+
# Gradio's own DOM or event routing, and a mode change is a client-side re-render with no server
|
| 357 |
+
# call. Server-rendered so the first paint already carries them, like StatusLine. The defaults
|
| 358 |
+
# are D-01's first visit: every kanji annotated, level N5.
|
| 359 |
+
READING_CONTROLS_HTML = (
|
| 360 |
+
'<div id="reading-controls" class="reading-controls">'
|
| 361 |
+
'<label for="furigana-mode">Furigana</label>'
|
| 362 |
+
'<select id="furigana-mode" aria-label="Furigana mode">'
|
| 363 |
+
'<option value="always" selected>always</option><option value="above">above my level</option>'
|
| 364 |
+
'<option value="never">never</option></select>'
|
| 365 |
+
'<label for="level-select">My level</label>'
|
| 366 |
+
'<select id="level-select" aria-label="JLPT level">'
|
| 367 |
+
'<option value="N5" selected>N5</option><option value="N4">N4</option>'
|
| 368 |
+
'<option value="N3">N3</option><option value="N2">N2</option></select>'
|
| 369 |
+
"</div>"
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
# Server-rendered so the first paint already carries it; the inner ids are what the host script
|
| 373 |
+
# writes to, and the wrappers are Gradio's own elements that tests select by elem_id. The
|
| 374 |
+
# stylesheet link rides inside the same value: avatar/ is a static path, so the browser fetches
|
| 375 |
+
# transcript.css from the app itself (the ruby rules the browser suites measure - plan 02-07).
|
| 376 |
+
TRANSCRIPT_HTML = (
|
| 377 |
+
'<link rel="stylesheet" href="/gradio_api/file=avatar/transcript.css">'
|
| 378 |
+
'<div id="transcript-text" class="transcript" aria-live="polite"></div>'
|
| 379 |
+
)
|
| 380 |
LATENCY_HTML = '<div id="latency-text" class="latency">dispatch→speech: —</div>'
|
| 381 |
ASR_BADGE_HTML = (
|
| 382 |
'<div id="asr-tier-text" class="asr-badge">ASR: loads in your browser on the first push</div>'
|
|
|
|
| 450 |
with gr.Column(scale=2, min_width=280):
|
| 451 |
StatusLine()
|
| 452 |
gr.HTML(value=INTRO_HTML, elem_id="intro-html")
|
| 453 |
+
gr.HTML(value=READING_CONTROLS_HTML, elem_id="reading-controls-html")
|
| 454 |
gr.HTML(value=TRANSCRIPT_HTML, elem_id="transcript")
|
| 455 |
with gr.Row():
|
| 456 |
gr.Button("Hold to talk", elem_id="ptt-button", variant="secondary")
|
tests/test_transport_seam.py
CHANGED
|
@@ -366,7 +366,12 @@ def test_host_glue_is_neither_a_transport_nor_the_turn_loop():
|
|
| 366 |
assert component.count(f"import('/gradio_api/file=avatar/{HOST}')") == 1, (
|
| 367 |
"the shared boot template must load host.js exactly once, for both transports"
|
| 368 |
)
|
| 369 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
|
| 371 |
|
| 372 |
# ------------------------------------------------------------------ plan 01-11: audio unlock
|
|
@@ -580,3 +585,46 @@ def test_transcript_uses_ruby_not_parentheses():
|
|
| 580 |
assert "createElement('ruby')" in s or 'createElement("ruby")' in s
|
| 581 |
assert "createElement('rt')" in s or 'createElement("rt")' in s
|
| 582 |
assert "'rp'" not in s and '"rp"' not in s, "no <rp>: it would leak into textContent"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
assert component.count(f"import('/gradio_api/file=avatar/{HOST}')") == 1, (
|
| 367 |
"the shared boot template must load host.js exactly once, for both transports"
|
| 368 |
)
|
| 369 |
+
# Plan 02-07: the transcript renderer is the third argument, loaded from the same
|
| 370 |
+
# template exactly once so both transports render identical ruby.
|
| 371 |
+
assert component.count(f"import('/gradio_api/file=avatar/{TRANSCRIPT}')") == 1, (
|
| 372 |
+
"the shared boot template must load transcript.js exactly once, for both transports"
|
| 373 |
+
)
|
| 374 |
+
assert "bindHost(avatar, document, transcript)" in component
|
| 375 |
|
| 376 |
|
| 377 |
# ------------------------------------------------------------------ plan 01-11: audio unlock
|
|
|
|
| 585 |
assert "createElement('ruby')" in s or 'createElement("ruby")' in s
|
| 586 |
assert "createElement('rt')" in s or 'createElement("rt")' in s
|
| 587 |
assert "'rp'" not in s and '"rp"' not in s, "no <rp>: it would leak into textContent"
|
| 588 |
+
|
| 589 |
+
|
| 590 |
+
# host.js's side of the transcript (plan 02-07): every avatar line rendered from the turn
|
| 591 |
+
# event's tokens, the reading preferences persisted through exactly two guarded helpers, and
|
| 592 |
+
# the furigana numbers seeded on __debug so getDebug() carries them from the first read.
|
| 593 |
+
|
| 594 |
+
|
| 595 |
+
def test_host_renders_every_avatar_line():
|
| 596 |
+
"""Phase 1 rendered only greeting / slower lines (the echo repeated the learner). With
|
| 597 |
+
furigana the avatar's reading of the same words is the point: every turn renders."""
|
| 598 |
+
host = src(HOST)
|
| 599 |
+
assert host.count("setTokens(") >= 2, "learner AND avatar lines must go through setTokens"
|
| 600 |
+
assert "greeting || speed < 1" not in host, "the Phase 1 skip-the-echo condition survives"
|
| 601 |
+
assert "createTranscript(" in host
|
| 602 |
+
assert ".analyze(" in host, "learner lines are tokenised through Avatar.analyze()"
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
def test_host_persists_reading_prefs():
|
| 606 |
+
"""Both keys are written and read, and the storage is touched ONLY inside the two
|
| 607 |
+
helpers - each wrapped in try - because Safari in a cross-origin iframe throws on the
|
| 608 |
+
access itself (research Pitfall 9) and an unguarded read would take the whole host down."""
|
| 609 |
+
host = src(HOST)
|
| 610 |
+
assert host.count("jla.furigana") >= 1 and host.count("jla.level") >= 1
|
| 611 |
+
assert "STORAGE_MODE_KEY" in host and "STORAGE_LEVEL_KEY" in host
|
| 612 |
+
remainder = host
|
| 613 |
+
for signature in ("function storageRead(", "function storageWrite("):
|
| 614 |
+
body = "\n".join(_function_body(host, signature))
|
| 615 |
+
assert "try" in body, f"{signature} has no try around the storage access"
|
| 616 |
+
assert "localStorage" in body, f"{signature} does not touch localStorage"
|
| 617 |
+
remainder = remainder.replace(body, "")
|
| 618 |
+
assert "localStorage" not in remainder, (
|
| 619 |
+
"localStorage is accessed outside storageRead / storageWrite in host.js"
|
| 620 |
+
)
|
| 621 |
+
assert "'unavailable'" in host and "'ok'" in host, "furigana.storage must publish both states"
|
| 622 |
+
|
| 623 |
+
|
| 624 |
+
def test_host_seeds_language_debug():
|
| 625 |
+
"""furigana.* is on __debug from bind, with or without a transcript module, so the
|
| 626 |
+
parity suite's key-set comparison never depends on timing."""
|
| 627 |
+
host = src(HOST)
|
| 628 |
+
assert "__debug.furigana" in host
|
| 629 |
+
for key in ("lastLineRt", "lastLineRtHeightPx", "lastLineHeightPx", "storage", "rtTotal"):
|
| 630 |
+
assert f"{key}:" in host, f"host.js does not seed furigana.{key}"
|