Spaces:
Running on Zero
fix(01-11): activation-aware resume grace; iframe stage context created at mount
Browse filesFound while proving the unlock under a strict autoplay policy:
- playBuffer's bounded resume() used one fixed grace. Under load the audio sink
can take seconds to start after a legitimate in-gesture unlock (0.7-5 s
measured on the headless fleet), and a fixed 2 s produced a false "audio is
blocked" error. Nothing separates "blocked" from "slow" except whether the
document has ever been activated, so the grace now follows
navigator.userActivation.hasBeenActive: 2 s when it never was (nothing can
unblock it - say so quickly), 8 s once it has (the resume is legitimate and
only waiting for the sink). Unknown (older browsers) is treated as activated.
- stage.html creates its AudioContext at 'avatar:mount', as the inline
transport does at boot, so audioState reads 'suspended' rather than 'none'
before the first gesture under the iframe transport and the strict-policy
precondition holds under both transports.
- avatar/audio-queue.js +16 -3
- avatar/stage.html +5 -0
|
@@ -13,8 +13,20 @@ const START_LEAD = 0.05; // a small lead so the first viseme is not already late
|
|
| 13 |
// blocked. Chromium keeps the resume() promise PENDING - neither resolved nor rejected -
|
| 14 |
// while its autoplay policy blocks the context, so an unbounded await would hang the turn
|
| 15 |
// forever with the status stuck on "speaking..."; the bound is what makes the refusal in
|
| 16 |
-
// playBuffer reachable.
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
// Shown by the host's status line. The remedy is the gesture: every control resumes the
|
| 20 |
// context inside its own tap (stagePort.unlockAudio), so one tap is enough.
|
|
@@ -60,11 +72,12 @@ async function toAudioBuffer(audioCtx, input) {
|
|
| 60 |
/** resume() with a deadline: see RESUME_GRACE_MS for why the deadline exists. */
|
| 61 |
async function resumeBounded(audioCtx) {
|
| 62 |
if (audioCtx.state === 'running') return;
|
|
|
|
| 63 |
let timer = null;
|
| 64 |
await Promise.race([
|
| 65 |
audioCtx.resume().catch(() => {}),
|
| 66 |
new Promise((resolve) => {
|
| 67 |
-
timer = setTimeout(resolve,
|
| 68 |
}),
|
| 69 |
]);
|
| 70 |
clearTimeout(timer);
|
|
|
|
| 13 |
// blocked. Chromium keeps the resume() promise PENDING - neither resolved nor rejected -
|
| 14 |
// while its autoplay policy blocks the context, so an unbounded await would hang the turn
|
| 15 |
// forever with the status stuck on "speaking..."; the bound is what makes the refusal in
|
| 16 |
+
// playBuffer reachable. There is no signal that separates "blocked" from "slow", so the
|
| 17 |
+
// wait depends on the one fact that predicts it: whether the document has ever received a
|
| 18 |
+
// user gesture (navigator.userActivation.hasBeenActive). Never activated - nothing can
|
| 19 |
+
// unblock it, say so quickly. Activated - the resume() is legitimate and only waiting for
|
| 20 |
+
// the audio sink to start: a few milliseconds on a device, 0.7-5 s measured on the
|
| 21 |
+
// headless test fleet under load, so the wait is generous rather than a false error.
|
| 22 |
+
const RESUME_GRACE_MS = 2000;
|
| 23 |
+
const RESUME_GRACE_ACTIVATED_MS = 8000;
|
| 24 |
+
|
| 25 |
+
function documentHasBeenActivated() {
|
| 26 |
+
const activation = typeof navigator !== 'undefined' ? navigator.userActivation : null;
|
| 27 |
+
// Unknown (older browsers): assume it has, and prefer a longer wait to a false error.
|
| 28 |
+
return activation ? activation.hasBeenActive : true;
|
| 29 |
+
}
|
| 30 |
|
| 31 |
// Shown by the host's status line. The remedy is the gesture: every control resumes the
|
| 32 |
// context inside its own tap (stagePort.unlockAudio), so one tap is enough.
|
|
|
|
| 72 |
/** resume() with a deadline: see RESUME_GRACE_MS for why the deadline exists. */
|
| 73 |
async function resumeBounded(audioCtx) {
|
| 74 |
if (audioCtx.state === 'running') return;
|
| 75 |
+
const grace = documentHasBeenActivated() ? RESUME_GRACE_ACTIVATED_MS : RESUME_GRACE_MS;
|
| 76 |
let timer = null;
|
| 77 |
await Promise.race([
|
| 78 |
audioCtx.resume().catch(() => {}),
|
| 79 |
new Promise((resolve) => {
|
| 80 |
+
timer = setTimeout(resolve, grace);
|
| 81 |
}),
|
| 82 |
]);
|
| 83 |
clearTimeout(timer);
|
|
@@ -195,6 +195,11 @@
|
|
| 195 |
const HANDLERS = {
|
| 196 |
'avatar:mount': async (m) => {
|
| 197 |
await ensureStage(m.vrmUrl);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
return snapshot();
|
| 199 |
},
|
| 200 |
'avatar:speak': (m) => speak(m.payload),
|
|
|
|
| 195 |
const HANDLERS = {
|
| 196 |
'avatar:mount': async (m) => {
|
| 197 |
await ensureStage(m.vrmUrl);
|
| 198 |
+
// Parity with the inline transport, which constructs its context at boot: the
|
| 199 |
+
// frame's context exists from mount, so audioState reads 'suspended' - not
|
| 200 |
+
// 'none' - before the first gesture, and the strict-policy precondition holds
|
| 201 |
+
// under both transports.
|
| 202 |
+
ctx();
|
| 203 |
return snapshot();
|
| 204 |
},
|
| 205 |
'avatar:speak': (m) => speak(m.payload),
|