buninu-browser 0.7.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/AGENTS.md +5 -0
  2. package/README.md +90 -1
  3. package/buninu-browser.js +1 -1
  4. package/frontends/wayland/README.md +35 -2
  5. package/frontends/wayland/client.js +8 -6
  6. package/frontends/wayland/clipboard.js +1 -1
  7. package/frontends/wayland/index.js +1 -1
  8. package/frontends/wayland/input.js +1 -1
  9. package/frontends/wayland/resource-io.js +6 -0
  10. package/frontends/wayland/transport-posix.js +120 -0
  11. package/frontends/wayland/transport-win32.js +9 -0
  12. package/frontends/wayland/transport.js +3 -120
  13. package/frontends/wayland/win32-resources.js +80 -0
  14. package/frontends/wayland/win32-wire.js +62 -0
  15. package/lib/audio/biquad.js +25 -0
  16. package/lib/audio/piano.js +6 -3
  17. package/lib/audio/playback-buffer.js +5 -5
  18. package/lib/audio/pulse-client.js +24 -5
  19. package/lib/audio/web-audio.js +570 -0
  20. package/lib/browser-dialog.js +126 -0
  21. package/lib/cdp-server.js +43 -11
  22. package/lib/file-picker.js +48 -0
  23. package/lib/headless-shell.js +34 -12
  24. package/lib/layout/text-layout.js +112 -19
  25. package/lib/media/mp4-frame-times.js +93 -0
  26. package/lib/paint/direct-canvas.ts +39 -0
  27. package/lib/paint/display-list.js +37 -12
  28. package/lib/paint/raster-readback.js +16 -7
  29. package/lib/renderer/animation-frames.js +10 -5
  30. package/lib/renderer/bindings.js +69 -16
  31. package/lib/renderer/canvas-2d.js +265 -15
  32. package/lib/renderer/canvas-round-rect.js +36 -0
  33. package/lib/renderer/css-animations.js +173 -0
  34. package/lib/renderer/dialog-client.js +30 -0
  35. package/lib/renderer/dialog-worker.js +35 -0
  36. package/lib/renderer/eval-environment.js +28 -0
  37. package/lib/renderer/file-input.js +49 -0
  38. package/lib/renderer/host.js +33 -4
  39. package/lib/renderer/html-drag.js +77 -0
  40. package/lib/renderer/image-loading.js +45 -2
  41. package/lib/renderer/main.js +4 -0
  42. package/lib/renderer/module-transform.js +5 -3
  43. package/lib/renderer/page-builtins.js +59 -0
  44. package/lib/renderer/page-realm.js +177 -40
  45. package/lib/renderer/page-renderer.js +397 -49
  46. package/lib/renderer/piano-input.js +3 -1
  47. package/lib/renderer/raster-surface.js +18 -0
  48. package/lib/renderer/script-rewrite.js +165 -9
  49. package/lib/renderer/video-presentation.js +5 -4
  50. package/lib/renderer/webgl/clip.js +24 -0
  51. package/lib/renderer/webgl/context.js +158 -0
  52. package/lib/renderer/webgl/cpu-fragment.js +211 -0
  53. package/lib/renderer/webgl/draw-cache.js +22 -0
  54. package/lib/renderer/webgl/pipeline.js +70 -0
  55. package/lib/renderer/webgl/shaders.js +58 -0
  56. package/lib/renderer/webgl/textures.js +43 -0
  57. package/lib/renderer/window-named-properties.js +46 -0
  58. package/lib/screenshot-cli.js +4 -7
  59. package/lib/style/animation-values.js +29 -0
  60. package/lib/style/computed-style.js +109 -13
  61. package/lib/style/transform.js +44 -0
  62. package/lib/window-size.js +10 -0
  63. package/package.json +1 -1
  64. package/patches/happy-dom/0007-scope-content-models-to-html-namespace.patch +29 -0
  65. package/patches/happy-dom/README.md +16 -0
  66. package/test/animation-frames.test.js +15 -0
  67. package/test/canvas-2d.test.js +252 -4
  68. package/test/canvas-frame-pacing.test.js +7 -0
  69. package/test/cdp-viewers.test.js +36 -0
  70. package/test/classic-games-patches/0001-fix-game-initialization.patch +177 -0
  71. package/test/classic-games-patches/README.md +156 -0
  72. package/test/classic-games-scenarios.js +133 -0
  73. package/test/clipboard.test.js +16 -0
  74. package/test/computed-style.test.js +18 -0
  75. package/test/css-animation-lifecycle.test.js +28 -0
  76. package/test/css-animations.test.js +83 -0
  77. package/test/direct-canvas.test.js +39 -0
  78. package/test/dynamic-inline-handlers.test.js +25 -0
  79. package/test/eval-declarations.test.js +22 -0
  80. package/test/file-picker.test.js +34 -0
  81. package/test/fixtures/canvas-damage.js +23 -0
  82. package/test/fixtures/canvas-frame-pacing.js +35 -0
  83. package/test/fixtures/direct-canvas.js +96 -0
  84. package/test/fixtures/javascript-dialogs.html +8 -0
  85. package/test/fixtures/media-audio-lifetime.js +65 -0
  86. package/test/fixtures/renderer-memory.js +7 -5
  87. package/test/fixtures/video-black-replay.js +8 -6
  88. package/test/fixtures/video-slow-end.js +39 -0
  89. package/test/fixtures/web-audio-renderer.js +45 -0
  90. package/test/flex.test.js +26 -0
  91. package/test/grid.test.js +13 -0
  92. package/test/headless-shell.test.js +9 -0
  93. package/test/headless-window-size.test.js +28 -0
  94. package/test/html-rcdata.test.js +35 -0
  95. package/test/image-decode.test.js +18 -0
  96. package/test/javascript-dialog.test.js +83 -0
  97. package/test/manual-breakout-paddle.js +42 -0
  98. package/test/manual-breakout-static.js +35 -0
  99. package/test/manual-classic-games.js +41 -0
  100. package/test/manual-clumsy-bird-three-pipes.js +81 -0
  101. package/test/manual-clumsy-bird.js +52 -0
  102. package/test/manual-hextris.js +30 -0
  103. package/test/manual-numbered-music.js +24 -0
  104. package/test/manual-t-rex-runner.js +49 -0
  105. package/test/manual-threejs-scene.js +64 -0
  106. package/test/manual-webgl-benchmark.js +27 -0
  107. package/test/manual-webgl-cpu.html +26 -0
  108. package/test/media-audio-lifetime.test.js +8 -0
  109. package/test/mp4-frame-times.test.js +33 -0
  110. package/test/page-scripts.test.js +96 -1
  111. package/test/piano-input.test.js +15 -2
  112. package/test/piano.test.js +11 -2
  113. package/test/playback-buffer.test.js +11 -0
  114. package/test/pointer-drag.test.js +33 -0
  115. package/test/positioning.test.js +82 -0
  116. package/test/pulse-client.test.js +51 -2
  117. package/test/raster-readback.test.js +14 -0
  118. package/test/script-rewrite.test.js +61 -3
  119. package/test/video-slow-end.test.js +6 -0
  120. package/test/viewport-keyboard-overflow.test.js +25 -0
  121. package/test/wayland-win32-resources.test.js +47 -0
  122. package/test/wayland-win32-wire.test.js +59 -0
  123. package/test/web-audio-renderer.test.js +8 -0
  124. package/test/web-audio.test.js +264 -0
  125. package/test/webgl-clip.test.js +9 -0
  126. package/test/webgl-cpu-fragment.test.js +43 -0
  127. package/test/window-named-properties.test.js +41 -0
  128. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfig.ts +2 -2
  129. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfigContentModelEnum.ts +1 -0
  130. package/vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts +23 -3
  131. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +1 -1
  132. package/vendor/land-renderer/LICENSE +21 -0
  133. package/vendor/land-renderer/README.md +14 -0
  134. package/vendor/land-renderer/direct-compositor.ts +443 -0
  135. package/vendor/land-renderer/frame.js +88 -0
  136. package/vendor/land-renderer/png-fast.js +85 -0
  137. package/vendor/land-renderer/raw-frames.js +66 -0
  138. package/vendor/land-renderer/render-types.ts +7 -0
  139. package/vendor/land-renderer/renderer.ts +134 -0
  140. package/vendor/land-renderer/snapshot.js +37 -0
  141. package/vendor/land-renderer/viewport.js +18 -0
  142. package/vendor/ses/dist/ses.mjs +1 -1
@@ -0,0 +1,62 @@
1
+ // A Windows-only envelope around unchanged Wayland bytes and FD argument order.
2
+ // ACKs keep mapping names alive until the receiver has opened its own handles.
3
+ export class MappingConnection {
4
+ constructor(resources,removed=()=>{}){
5
+ this.resources=resources;this.removed=removed;this.fds=[];this.input=Buffer.alloc(0);this.wire=Buffer.alloc(0);
6
+ this.output=[];this.queuedBytes=0;this.pending=new Map();this.next=1;this.closed=false;
7
+ }
8
+ attach(socket){this.socket=socket;this.flush()}
9
+ packet(meta,data=Buffer.alloc(0)){
10
+ const json=Buffer.from(JSON.stringify(meta)),body=Buffer.alloc(8+json.length+data.length);
11
+ body.writeUInt32LE(4+json.length+data.length);body.writeUInt32LE(json.length,4);json.copy(body,8);data.copy(body,8+json.length);
12
+ if(this.queuedBytes+body.length>4*1024*1024)throw Error('Output queue limit');
13
+ this.output.push(body);this.queuedBytes+=body.length;this.flush();
14
+ }
15
+ send(bytes,fds=[]){
16
+ if(this.closed)return;
17
+ if(this.pending.size>=256)throw Error('Resource ACK queue limit');
18
+ const held=[];
19
+ try{
20
+ for(const fd of fds)held.push(this.resources.sendResource(fd));
21
+ const id=this.next++;if(held.length)this.pending.set(id,held);
22
+ this.packet({version:1,id,resources:held.map(h=>h.descriptor)},bytes);
23
+ }catch(e){for(const h of held)h.release();this.close();throw e}
24
+ }
25
+ flush(){
26
+ try { while(!this.closed&&this.socket&&this.output.length){
27
+ const b=this.output[0],n=this.socket.write(b);if(n<0){this.close();return}if(!n)return;
28
+ this.queuedBytes-=n;if(n<b.length){this.output[0]=b.subarray(n);return}this.output.shift();
29
+ }} catch(error){this.error=error;this.close()}
30
+ }
31
+ receive(bytes){
32
+ try{
33
+ this.wire=Buffer.concat([this.wire,bytes]);
34
+ if(this.wire.length>2*1024*1024)throw Error('Input queue limit');
35
+ while(this.wire.length>=8){
36
+ const size=this.wire.readUInt32LE(),jsonSize=this.wire.readUInt32LE(4);
37
+ if(size<4||size>1024*1024||jsonSize>65536||jsonSize>size-4)throw Error('Invalid envelope');
38
+ if(this.wire.length<size+4)return;
39
+ const meta=JSON.parse(this.wire.subarray(8,8+jsonSize).toString()),data=this.wire.subarray(8+jsonSize,size+4);
40
+ this.wire=this.wire.subarray(size+4);
41
+ if(meta.version!==1)throw Error('Unknown mapping transport version');
42
+ if(meta.ack!==undefined){
43
+ const held=this.pending.get(meta.ack);if(!held||data.length)throw Error('Invalid resource ACK');
44
+ this.pending.delete(meta.ack);for(const h of held)h.release();continue;
45
+ }
46
+ if(!Number.isSafeInteger(meta.id)||!Array.isArray(meta.resources)||meta.resources.length>256)throw Error('Invalid resource packet');
47
+ for(const d of meta.resources)this.fds.push(this.resources.receiveResource(d));
48
+ if(meta.resources.length)this.packet({version:1,ack:meta.id});
49
+ this.input=Buffer.concat([this.input,data]);
50
+ if(this.input.length>1024*1024||this.fds.length>256)throw Error('Wayland queue limit');
51
+ this.onData?.();if(this.closed)return;
52
+ }
53
+ }catch(error){this.error=error;this.close()}
54
+ }
55
+ poll(){this.flush()}
56
+ close(){
57
+ if(this.closed)return;this.closed=true;try{this.socket?.end()}catch{}
58
+ for(const held of this.pending.values())for(const h of held)h.release();this.pending.clear();
59
+ for(const fd of this.fds)this.resources.closeResource(fd);this.fds=[];this.output=[];this.queuedBytes=0;
60
+ try{this.onClose?.()}finally{this.removed()}
61
+ }
62
+ }
@@ -0,0 +1,25 @@
1
+ // Web Audio / Audio EQ Cookbook normalized second-order filter coefficients.
2
+ export function biquadCoefficients(type,frequency,q,gain,rate) {
3
+ const w=2*Math.PI*Math.max(0,Math.min(rate/2,frequency))/rate,c=Math.cos(w),s=Math.sin(w);
4
+ const A=10**(gain/40),alpha=s/(2*Math.max(1e-12,['lowpass','highpass'].includes(type)?10**(q/20):q));
5
+ let b0,b1,b2,a0=1+alpha,a1=-2*c,a2=1-alpha;
6
+ switch(type){
7
+ case 'lowpass':b0=(1-c)/2;b1=1-c;b2=b0;break;
8
+ case 'highpass':b0=(1+c)/2;b1=-(1+c);b2=b0;break;
9
+ case 'bandpass':b0=alpha;b1=0;b2=-alpha;break;
10
+ case 'notch':b0=1;b1=-2*c;b2=1;break;
11
+ case 'allpass':b0=1-alpha;b1=-2*c;b2=1+alpha;break;
12
+ case 'peaking':b0=1+alpha*A;b1=-2*c;b2=1-alpha*A;a0=1+alpha/A;a2=1-alpha/A;break;
13
+ case 'lowshelf':case 'highshelf':{
14
+ const t=Math.sqrt(2*A)*s;
15
+ if(type==='lowshelf'){
16
+ b0=A*((A+1)-(A-1)*c+t);b1=2*A*((A-1)-(A+1)*c);b2=A*((A+1)-(A-1)*c-t);
17
+ a0=(A+1)+(A-1)*c+t;a1=-2*((A-1)+(A+1)*c);a2=(A+1)+(A-1)*c-t;
18
+ }else{
19
+ b0=A*((A+1)+(A-1)*c+t);b1=-2*A*((A-1)+(A+1)*c);b2=A*((A+1)+(A-1)*c-t);
20
+ a0=(A+1)-(A-1)*c+t;a1=2*((A-1)-(A+1)*c);a2=(A+1)-(A-1)*c-t;
21
+ }break;
22
+ }
23
+ }
24
+ return [b0/a0,b1/a0,b2/a0,a1/a0,a2/a0];
25
+ }
@@ -4,8 +4,9 @@ import { floatToS16 } from "./pcm.js";
4
4
  // is relative to Space = MIDI 60 (C4). Change PIANO_DO alone to transpose
5
5
  // every key; the default A4 exactly matches LazyI's original octave.
6
6
  // The LazyI key intervals remain fixed in the table.
7
- // Browser control keys (Escape, Tab, CapsLock, Shift, Alt, Control, Backspace)
8
- // are intentionally omitted. Space and Enter remain playable as requested.
7
+ // Left hand: Escape, `, Tab, CapsLock, ShiftLeft play a ii chord (re la re fa
8
+ // la); AltRight (AltGr) continues . , m (do si la) down to sol. Other control
9
+ // keys (right Shift, Alt, Control, Backspace) are intentionally omitted.
9
10
  export const PIANO_KEYS = Object.freeze({
10
11
  F1: 29, F2: 36, F3: 41, F4: 45, F5: 48, F6: 53,
11
12
  F7: 81, F8: 83, F9: 84, F10: 86, F11: 88, F12: 89,
@@ -17,6 +18,7 @@ export const PIANO_KEYS = Object.freeze({
17
18
  k: 64, l: 65, ";": 67, "'": 69, Enter: 71,
18
19
  z: 28, x: 35, c: 40, v: 43, b: 47, n: 67, m: 57,
19
20
  ",": 59, ".": 60, "/": 62, " ": 60,
21
+ Escape: 26, Tab: 38, CapsLock: 41, ShiftLeft: 45, AltRight: 55,
20
22
  });
21
23
  export const PIANO_DO = 69; // A4, LazyI's original Space pitch.
22
24
 
@@ -24,6 +26,7 @@ const CODE_KEYS = {
24
26
  Backquote: "`", Minus: "-", Equal: "=", BracketLeft: "[", BracketRight: "]",
25
27
  Backslash: "\\", Semicolon: ";", Quote: "'", Comma: ",", Period: ".", Slash: "/",
26
28
  Space: " ", NumpadEnter: "Enter",
29
+ Escape: "Escape", Tab: "Tab", CapsLock: "CapsLock", ShiftLeft: "ShiftLeft", AltRight: "AltRight",
27
30
  };
28
31
  const SHIFTED_KEYS = {
29
32
  "~": "`", "!": "1", "@": "2", "#": "3", "$": "4", "%": "5", "^": "6",
@@ -37,7 +40,7 @@ export function pianoNote(key, code = "") {
37
40
  : /^Digit[0-9]$/.test(code) ? code.slice(5)
38
41
  : CODE_KEYS[code] ?? (code.startsWith("F") ? code : null);
39
42
  const typed = key.length === 1 ? (SHIFTED_KEYS[key] ?? key.toLowerCase())
40
- : key === "Space" || key === "Spacebar" ? " " : key;
43
+ : key === "Space" || key === "Spacebar" ? " " : key === "AltGraph" ? "AltRight" : key;
41
44
  const note = PIANO_KEYS[physical ?? typed];
42
45
  return note === undefined ? null : note + PIANO_DO - 60;
43
46
  }
@@ -1,12 +1,12 @@
1
1
  // Decoded PCM kept in the controller is independent of the device queue.
2
2
  // It can bridge renderer work without increasing pause/seek output latency.
3
3
  export class PlaybackBuffer {
4
- constructor(bytesPerSecond, frameBytes, leadSeconds) {
4
+ constructor(bytesPerSecond, frameBytes, leadSeconds, liveReserveSeconds = 0) {
5
5
  this.bytesPerSecond = bytesPerSecond;
6
6
  this.frameBytes = frameBytes;
7
- this.reserve = leadSeconds <= 0.15 ? 0 : this.align(bytesPerSecond * 2);
8
- this.threshold = this.reserve ? this.align(bytesPerSecond * 0.4) : 0;
9
- this.buffering = Boolean(this.reserve);
7
+ this.reserve = this.align(bytesPerSecond * (leadSeconds <= 0.15 ? Math.max(0, Math.min(.5, liveReserveSeconds)) : 2));
8
+ this.threshold = leadSeconds <= 0.15 ? 0 : this.align(bytesPerSecond * 0.4);
9
+ this.buffering = Boolean(this.threshold);
10
10
  }
11
11
 
12
12
  align(bytes) {
@@ -14,7 +14,7 @@ export class PlaybackBuffer {
14
14
  }
15
15
 
16
16
  ready(queued, inDevice, finishing) {
17
- if (!this.reserve || finishing) return true;
17
+ if (!this.threshold || finishing) return true;
18
18
  if (queued < this.frameBytes && inDevice < this.frameBytes) this.buffering = true;
19
19
  if (this.buffering && queued >= this.threshold) this.buffering = false;
20
20
  return !this.buffering;
@@ -259,7 +259,7 @@ export class PulseClient {
259
259
  * `onRequest(bytes)` asks for about that much more audio, paced to real time
260
260
  * (see PlaybackStream).
261
261
  */
262
- async openPlayback({ rate, channels, name = "audio", onRequest = () => {}, onTiming = null, onTimingError = null, leadSeconds = LEAD_SECONDS }) {
262
+ async openPlayback({ rate, channels, name = "audio", onRequest = () => {}, onTiming = null, onTimingError = null, leadSeconds = LEAD_SECONDS, adaptiveLead = false }) {
263
263
  const spec = { format: SAMPLE_S16LE, channels, rate };
264
264
  const frameBytes = channels * 2;
265
265
  const bytesForSeconds = (seconds) => Math.max(frameBytes, Math.round(rate * seconds) * frameBytes);
@@ -267,7 +267,7 @@ export class PulseClient {
267
267
  // stream that is deliberately kept only 120 ms ahead would not start until
268
268
  // that buffer filled. Request an explicit, short start threshold instead.
269
269
  const interactive = leadSeconds <= 0.15;
270
- const targetLength = bytesForSeconds(interactive ? 0.16 : 0.4);
270
+ const targetLength = bytesForSeconds(adaptiveLead ? 0.55 : interactive ? 0.16 : 0.4);
271
271
  const prebuffer = bytesForSeconds(interactive ? 0.04 : 0.08);
272
272
  const minimumRequest = bytesForSeconds(0.04);
273
273
  const reply = await this.#command(COMMAND.CREATE_PLAYBACK_STREAM, (w) => w
@@ -279,7 +279,7 @@ export class PulseClient {
279
279
  const index = reply.u32();
280
280
  reply.u32(); // sink input index
281
281
  const requested = reply.u32();
282
- const stream = new PlaybackStream(this, index, spec, onRequest, leadSeconds, onTiming, onTimingError);
282
+ const stream = new PlaybackStream(this, index, spec, onRequest, leadSeconds, onTiming, onTimingError, adaptiveLead);
283
283
  this.#streams.set(index, stream);
284
284
  stream.grant(requested);
285
285
  return stream;
@@ -338,6 +338,9 @@ export class PulseClient {
338
338
  * the output. That reserve is discarded on close, not sent to the device.
339
339
  */
340
340
  const LEAD_SECONDS = 0.5;
341
+ // Live synthesis keeps a small PCM reserve independent of server credits.
342
+ // Unlike decoded media's two-second reserve, this has no startup threshold.
343
+ const LIVE_RESERVE_SECONDS = 0.32;
341
344
  const PACE_MS = 50;
342
345
  // Video follows this measured clock. Sharing the 50 ms PCM timer would cap
343
346
  // presentation at 20 FPS even when decoding and output can keep up.
@@ -365,9 +368,11 @@ class PlaybackStream {
365
368
  #timingPending = false;
366
369
  #playedBytes = 0;
367
370
  #producerBuffer;
371
+ #adaptiveLead;
372
+ #requestedAt = null;
368
373
  killed = false;
369
374
 
370
- constructor(client, index, { rate, channels }, onRequest, leadSeconds, onTiming, onTimingError) {
375
+ constructor(client, index, { rate, channels }, onRequest, leadSeconds, onTiming, onTimingError, adaptiveLead) {
371
376
  this.#client = client;
372
377
  this.index = index;
373
378
  // Audio is only ever cut at whole frames (one sample of every channel):
@@ -377,7 +382,8 @@ class PlaybackStream {
377
382
  this.#bytesPerSecond = rate * this.#frameBytes;
378
383
  this.#onRequest = onRequest;
379
384
  this.#leadSeconds = leadSeconds;
380
- this.#producerBuffer = new PlaybackBuffer(this.#bytesPerSecond, this.#frameBytes, leadSeconds);
385
+ this.#adaptiveLead = adaptiveLead;
386
+ this.#producerBuffer = new PlaybackBuffer(this.#bytesPerSecond, this.#frameBytes, leadSeconds, adaptiveLead ? LIVE_RESERVE_SECONDS : 0);
381
387
  this.#onTiming = onTiming;
382
388
  this.#onTimingError = onTimingError;
383
389
  this.#timer = setInterval(() => this.#pace(), PACE_MS);
@@ -414,7 +420,19 @@ class PlaybackStream {
414
420
  /** Queues s16le audio from the producer; it goes out as the clock allows. */
415
421
  write(bytes) {
416
422
  if (this.killed || !bytes.length) return;
423
+ if (this.#adaptiveLead && this.#requestedAt !== null) {
424
+ // Live synthesis runs in the page renderer. Learn its response budget
425
+ // under expensive Canvas/JS work without pre-rendering seconds of future
426
+ // input. The separate controller can play this bounded device queue.
427
+ const responseSeconds=(performance.now()-this.#requestedAt)/1000;
428
+ if(responseSeconds>this.#leadSeconds-0.05)
429
+ this.#leadSeconds=Math.min(0.5,Math.max(this.#leadSeconds,responseSeconds+0.1));
430
+ // Later pieces intentionally yield for page scheduling; measure only
431
+ // the first response, not the time to complete the entire reserve.
432
+ this.#requestedAt=null;
433
+ }
417
434
  this.#outstanding = Math.max(0, this.#outstanding - bytes.length);
435
+ if (!this.#outstanding) this.#requestedAt=null;
418
436
  this.#queue.push(bytes);
419
437
  this.#queuedBytes += bytes.length;
420
438
  this.#pace();
@@ -472,6 +490,7 @@ class PlaybackStream {
472
490
  const sentNow = inDevice + this.#offset - previousOffset;
473
491
  const need = this.#producerBuffer.request(this.#queuedBytes, this.#outstanding, sentNow, immediateNeed);
474
492
  if (need >= this.#bytesPerSecond * MIN_REQUEST_SECONDS && !this.finishing) {
493
+ this.#requestedAt ??= performance.now();
475
494
  this.#outstanding += need;
476
495
  this.#onRequest(need);
477
496
  }