WolfDavid commited on
Commit
90f70d6
·
1 Parent(s): 6b68c4d

fix(01-11): activation-aware resume grace; iframe stage context created at mount

Browse files

Found 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.

Files changed (2) hide show
  1. avatar/audio-queue.js +16 -3
  2. avatar/stage.html +5 -0
avatar/audio-queue.js CHANGED
@@ -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. A context that is allowed to run resumes in a few milliseconds.
17
- const RESUME_GRACE_MS = 1000;
 
 
 
 
 
 
 
 
 
 
 
 
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, RESUME_GRACE_MS);
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);
avatar/stage.html CHANGED
@@ -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),