buninu-browser 0.1.0 → 0.2.0

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 (57) hide show
  1. package/AGENTS.md +17 -0
  2. package/LICENSES/brotli.txt +19 -0
  3. package/README.md +41 -7
  4. package/THIRD_PARTY_NOTICES.md +26 -4
  5. package/buninu-browser.js +10 -2
  6. package/lib/audio/pcm.js +16 -0
  7. package/lib/audio/piano.js +109 -0
  8. package/lib/audio/pulse-client.js +413 -0
  9. package/lib/cdp-server.js +25 -6
  10. package/lib/document-types.js +308 -0
  11. package/lib/download-directory.js +28 -0
  12. package/lib/download.js +63 -0
  13. package/lib/headless-shell.js +108 -14
  14. package/lib/input/hit-test.js +57 -8
  15. package/lib/layout/text-layout.js +112 -27
  16. package/lib/network/network-service.js +7 -3
  17. package/lib/network/resolver.js +16 -0
  18. package/lib/paint/display-list.js +2 -2
  19. package/lib/render-tree/index.js +13 -0
  20. package/lib/renderer/bindings.js +541 -53
  21. package/lib/renderer/flags.js +8 -5
  22. package/lib/renderer/host.js +102 -6
  23. package/lib/renderer/main.js +35 -3
  24. package/lib/renderer/module-transform.js +30 -23
  25. package/lib/renderer/page-realm.js +226 -22
  26. package/lib/renderer/page-renderer.js +1293 -212
  27. package/lib/screenshot-cli.js +61 -0
  28. package/lib/style/computed-style.js +268 -20
  29. package/lib/user-agent.js +12 -0
  30. package/package.json +1 -1
  31. package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
  32. package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
  33. package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
  34. package/patches/happy-dom/README.md +12 -0
  35. package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
  36. package/patches/ses/README.md +16 -0
  37. package/test/computed-style.test.js +52 -0
  38. package/test/document-types.test.js +111 -0
  39. package/test/download.test.js +192 -0
  40. package/test/fixtures/tone.mp3 +0 -0
  41. package/test/flex.test.js +11 -0
  42. package/test/happy-dom-matches.test.js +25 -0
  43. package/test/headless-shell.test.js +27 -0
  44. package/test/hit-test.test.js +37 -0
  45. package/test/page-scripts.test.js +388 -29
  46. package/test/piano.test.js +65 -0
  47. package/test/positioning.test.js +11 -0
  48. package/test/pulse-client.test.js +130 -0
  49. package/test/render-tree.test.js +14 -0
  50. package/test/resolver.test.js +12 -1
  51. package/test/screenshot-cli.test.js +19 -0
  52. package/test/text-layout.test.js +22 -0
  53. package/test/user-agent.test.js +42 -0
  54. package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
  55. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
  56. package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
  57. package/vendor/ses/dist/ses.mjs +2 -0
package/AGENTS.md CHANGED
@@ -7,6 +7,23 @@
7
7
  * Specifically, able to work with ../casty through CDP is an important goal
8
8
  - Only use Bun FFI to dlopen libc when other methods are clearly not enough
9
9
 
10
+ ## Implementing the browser
11
+ - Do not treat any website specially when refining browser features like layout css js. Fix the actual problem that all websites can benefit from.
12
+ - If you don't know how to fix a specific bug, look at ../chromium implementation but don't copy LGPL code.
13
+
14
+ ## Viewing files
15
+ - No file is unviewable: every response the browser navigates to must show something, never a "cannot be shown" page
16
+ * HTML is shown as itself; images and video/audio get a generated page like Chromium's standalone image and media documents
17
+ * The formats bunmsh `serve` previews (md, json, json5, jsonc, jsonl, ndjson, yaml, yml, toml, xml) get its preview under a "Pretty-print" checkbox, checked by default; unchecked shows the raw text
18
+ * Everything else, binary included, is shown as text
19
+ * Large files must not stall the renderer: past a size limit only their start is shown (and previews fall back to the text), with a bar at the top that says so and points to `download`
20
+ * See `lib/document-types.js`; `download` still saves the response's original bytes
21
+
22
+ ## Screenshots
23
+ - Use the headless CLI to inspect rendered pages: `bun index.js --headless --screenshot=/tmp/page.png --window-size=1280,800 URL` (or `bun buninu-browser.js ...`). It writes the image and exits; inspect it with the image viewer.
24
+ - Capture waits for the page's `load` event. For content that appears later, add `--screenshot-wait=1000` to wait real milliseconds after load. This is not Chromium's virtual time budget.
25
+ - `.png`, `.jpg`/`.jpeg`, and `.webp` filename extensions select the output format.
26
+
10
27
  ## Bun
11
28
  - Bun doesn't have `bun --check`
12
29
  - Bun has many built-in functions; use/check them first before implementing your own
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
package/README.md CHANGED
@@ -22,7 +22,7 @@ npm i -g bun
22
22
  npx buninu-browser
23
23
 
24
24
  # Open a URL
25
- npx buninu-browser github.com
25
+ npx buninu-browser buninu.org
26
26
 
27
27
  # Arguments after -- go to the
28
28
  # browser engine; --mobile presents
@@ -35,8 +35,18 @@ npx buninu-browser github.com \
35
35
  # endpoint for any client
36
36
  npx buninu-browser --headless \
37
37
  --remote-debugging-port=9222
38
+
39
+ # capture a page and exit
40
+ npx buninu-browser --headless --screenshot=page.png \
41
+ --window-size=1280,800 https://example.com
38
42
  ```
39
43
 
44
+ Screenshot mode waits for the page's `load` event before capture. For content
45
+ that appears later, add `--screenshot-wait=1000` (real milliseconds). The output
46
+ format follows the filename extension: PNG, JPEG, or WebP. Chromium's
47
+ `--virtual-time-budget` is not implemented; a real-time delay does not advance
48
+ page timers the same way.
49
+
40
50
  The first run downloads this package
41
51
  (about 50 MB: fonts, CanvasKit and
42
52
  the video decoder), then the terminal
@@ -206,11 +216,20 @@ When idle, the main thread rasterizes a tile reaching half a viewport above
206
216
  and one viewport below the visible area, so the compositor can scroll
207
217
  without waiting. Screenshots are encoded with `Bun.Image`.
208
218
 
209
- `<video>` can decode and display MP4 video frames after a click, with a basic
210
- play/pause control. Video bytes are fetched through the controller; the
211
- renderer uses vendored wasmpeg JavaScript and WebAssembly without native
212
- media bindings. Audio, seeking, and full HTML media controls are not yet
213
- implemented. No npm install is needed for the decoder.
219
+ `<video>` can decode and display MP4 video frames with basic playback and
220
+ seeking. Video bytes are fetched through the controller; the renderer uses
221
+ vendored wasmpeg JavaScript and WebAssembly without native media bindings.
222
+ Audio plays through a PulseAudio server on 127.0.0.1:4713, such as jspulse.
223
+ No npm install is needed for the decoder.
224
+
225
+ Clicked `a download` links (including `data:` and `blob:` URLs up to 64 MB) and responses with `Content-Disposition: attachment`
226
+ save to the user's Downloads folder by default (including a relocated Windows
227
+ Downloads folder); the directory is created when needed. A CDP
228
+ client can set the directory with `Browser.setDownloadBehavior` and receive
229
+ `Browser.downloadWillBegin` and `Browser.downloadProgress` events. Standalone
230
+ text and structured-document previews have a Download button at the top right
231
+ that saves the original response bytes. As in Chromium, `allowAndName` saves
232
+ the file under its download GUID; `allow` keeps the suggested filename.
214
233
 
215
234
  Documents load in stages:
216
235
 
@@ -241,6 +260,21 @@ asked for; input coordinates are scaled back, and pages see the factor as
241
260
  CASTY_BROWSER="$PWD/buninu-browser.js" bun ../casty/bin/casty.js github.com -- --mobile
242
261
  ```
243
262
 
263
+ ### Video and audio
264
+
265
+ `<video>` and `<audio>` share one pipeline; an `<audio controls>` is a
266
+ 300x32 control bar with no picture. Clicking plays or pauses, clicking the
267
+ progress track seeks, and page script has `play()`, `pause()`,
268
+ `currentTime` (settable), `duration`, `paused`, `ended` and the matching
269
+ events. wasmpeg decodes what its LGPL build supports (H.264, HEVC, VP8/VP9,
270
+ MPEG-4, Theora, MPEG-1/2; MP3, AAC, FLAC, Vorbis, Opus, WAV).
271
+
272
+ Sound, a video's included, plays through a PulseAudio native-protocol server
273
+ on `127.0.0.1:4713`, such as [jspulse](https://github.com/jjtseng93/jspulse)
274
+ (`npx @drxiaozhi/jspulse`); without one, video plays silently. The controller
275
+ holds the connection, since renderers cannot open sockets, and paces the
276
+ stream to real time, so pausing stops the sound within half a second.
277
+
244
278
  ### Diagnostics
245
279
 
246
280
  casty does not show the browser's stderr. Set `BUNINU_LOG=1` to also write
@@ -271,7 +305,7 @@ The integration form is intentionally different for each upstream:
271
305
  | **Chromium** | Behavioural reference; Linux syscall tables and the renderer seccomp policy structure | Use only BSD-3-Clause files, with attribution; Blink files under other licenses (such as the LGPL `html.css`) are not used |
272
306
  | **WHATWG HTML Standard** | User-agent default styles | Adapt the suggested rendering rules (§15.3) with attribution under CC BY 4.0 |
273
307
  | **CanvasKit** | Skia-based WASM rasterization and text shaping (SkParagraph with its bundled HarfBuzz) | Pin the official `canvaskit-wasm` release artifacts and integrity; do not vendor the full Skia repository or a separate HarfBuzz |
274
- | **SES (Hardened JavaScript)** | First sandbox layer for page scripts: `lockdown()` and one compartment per document | Vendored unmodified at an exact npm release in [`vendor/ses/`](vendor/ses/); page globals are project-owned bindings, never Happy DOM objects |
308
+ | **SES (Hardened JavaScript)** | First sandbox layer for page scripts: `lockdown()` and one compartment per document | Vendored at an exact npm release in [`vendor/ses/`](vendor/ses/), with local changes recorded in [`patches/ses/`](patches/ses/); page globals are project-owned bindings, never Happy DOM objects |
275
309
  | **Acorn** | JavaScript parser used to give classic scripts browser-style shared globals under SES | Vendored unmodified at an exact npm release in [`vendor/acorn/`](vendor/acorn/) |
276
310
  | **wasmpeg / FFmpeg** | CPU WebAssembly video decoding | Vendored `@wasmpeg/cpu` 1.0.1 under [`vendor/wasmpeg/`](vendor/wasmpeg/), licensed LGPL-2.1-or-later; the WASM remains a separate, replaceable file. See its [`VENDORING.md`](vendor/wasmpeg/VENDORING.md) and [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) |
277
311
  | **Web Platform Tests** | Conformance testing | Use a pinned external checkout or sparse test snapshot; do not place the complete WPT repository in the runtime vendor tree |
@@ -36,8 +36,11 @@ corresponding test.
36
36
  (core-js, MIT), `LICENSES/ses-v8.txt` (V8, BSD-3-Clause)
37
37
 
38
38
  The renderer calls `lockdown()` and runs page scripts in SES compartments
39
- (`lib/renderer/page-realm.js`). The bundle is used unmodified; its SHA-256 is
39
+ (`lib/renderer/page-realm.js`). The upstream bundle's SHA-256 is
40
40
  `1e40a59ccf5e72da8e260dda007a19ebd20304394b1b7288d0aab21733b9cd95`.
41
+ The local change allowing a `RegExp` instance to override its own `toString`
42
+ is recorded in [`patches/ses/`](patches/ses/README.md). The patched bundle's
43
+ SHA-256 is `2f6c0043af15b4f7a7b04bfae02ce266a32f92b1089c956697c6f7b439b063bf`.
41
44
 
42
45
  ## Acorn
43
46
 
@@ -148,6 +151,15 @@ reimplemented in JavaScript, and no Chromium source text is copied:
148
151
  tile (`lib/renderer/page-renderer.js`), after the display-item reuse in
149
152
  `third_party/blink/renderer/platform/graphics/paint/paint_controller.cc`.
150
153
 
154
+ ## PulseAudio native protocol (independent implementation)
155
+
156
+ `lib/audio/pulse-client.js` is the client side of the PulseAudio native
157
+ protocol (protocol version 8: command packets as tagstructs, audio frames with
158
+ a 20-byte header), written for this project from the protocol's documented
159
+ wire format. It contains no source code from PulseAudio or from jspulse, both
160
+ of which are LGPL-licensed; jspulse was used only as a separate server process
161
+ to test against, never imported by this repository's code or tests.
162
+
151
163
  ## WHATWG HTML Standard
152
164
 
153
165
  - Project: HTML Living Standard
@@ -214,12 +226,22 @@ binary and CanvasKit's upstream build configuration:
214
226
  | libwebp | `LICENSES/libwebp.txt` (BSD-3-Clause) | https://github.com/webmproject/libwebp |
215
227
  | Wuffs image decoding | `LICENSES/wuffs.txt` (MIT or Apache-2.0) | https://github.com/google/wuffs |
216
228
  | WOFF2 fonts | `LICENSES/woff2.txt` (MIT) | https://github.com/google/woff2 |
229
+ | Brotli compression used by WOFF2 | `LICENSES/brotli.txt` (MIT) | https://github.com/google/brotli |
217
230
  | zlib | `LICENSES/zlib.txt` (zlib) | https://github.com/madler/zlib |
218
231
  | Emscripten-generated runtime | `LICENSES/emscripten.txt` (MIT or University of Illinois/NCSA) | https://github.com/emscripten-core/emscripten |
219
232
 
220
- The exact revisions of these libraries in the inherited CanvasKit binary
221
- have not been independently reconstructed; the top-level artifact is the
222
- official `canvaskit-wasm` 0.41.1 release.
233
+ The top-level artifact is the official `canvaskit-wasm` 0.41.1 release,
234
+ corresponding to Skia commit `3c68f3ffd7c9bc781494cdb85e718ff1e6f49d84`.
235
+ That release's `DEPS` pins Brotli at
236
+ `6d03dfbedda1615c4cba1211f8d81735575209c8`. The exact revisions of the
237
+ remaining embedded libraries have not been independently reconstructed.
238
+
239
+ ## Project-generated test media
240
+
241
+ `test/fixtures/tone.mp3` and `test/fixtures/video.mp4` are one-second synthetic
242
+ fixtures generated for this project's audio and video tests. They contain no
243
+ third-party creative content. Their container metadata identifies FFmpeg's
244
+ Lavf/Lavc encoders; FFmpeg itself is not distributed in these fixture files.
223
245
 
224
246
  The source asset directory also retains the license files next to the fonts and
225
247
  CanvasKit artifacts. Versions and provenance above mirror
package/buninu-browser.js CHANGED
@@ -36,7 +36,7 @@ export async function main(args = Bun.argv.slice(2)) {
36
36
  if (has("--help", "-h", "-help")) {
37
37
  console.log(`Buninu Browser ${packageInfo.version}
38
38
 
39
- Usage: buninu-browser [options]
39
+ Usage: buninu-browser [options] [URL]
40
40
 
41
41
  Options:
42
42
  --remote-debugging-port=<port> CDP port (0 picks a free one; default 9222)
@@ -45,6 +45,9 @@ Options:
45
45
  --mobile Present as a phone: mobile user agent and touch media
46
46
  features (hover: none, pointer: coarse); user agent
47
47
  overrides over CDP are ignored
48
+ --screenshot[=FILE] Capture one PNG and exit (default screenshot.png)
49
+ --window-size=WIDTH,HEIGHT Viewport size for the screenshot (default 800,600)
50
+ --screenshot-wait=MILLISECONDS Wait after page load before capturing
48
51
  --version, -V Print the version
49
52
  --help, -h Print this help
50
53
 
@@ -56,7 +59,12 @@ Environment:
56
59
  }
57
60
 
58
61
  const { runHeadlessShell } = await import("./lib/headless-shell.js");
59
- await runHeadlessShell(args);
62
+ if (args.some((arg) => arg === "--screenshot" || arg.startsWith("--screenshot="))) {
63
+ const { runScreenshot } = await import("./lib/screenshot-cli.js");
64
+ await runScreenshot(args);
65
+ } else {
66
+ await runHeadlessShell(args);
67
+ }
60
68
  return 0;
61
69
  }
62
70
 
@@ -0,0 +1,16 @@
1
+ /** Interleaved Float32 samples in [-1, 1] (as audio decoders produce) as s16le bytes. */
2
+ export function floatToS16(samples) {
3
+ const out = new Uint8Array(samples.length * 2);
4
+ const view = new DataView(out.buffer);
5
+ for (let index = 0; index < samples.length; index++) {
6
+ const value = Math.max(-1, Math.min(1, samples[index]));
7
+ view.setInt16(index * 2, Math.round(value < 0 ? value * 32768 : value * 32767), true);
8
+ }
9
+ return out;
10
+ }
11
+
12
+ /** Consume at most `bytes` of interleaved s16le output, leaving whole frames. */
13
+ export function splitSamples(samples, channels, bytes) {
14
+ const count = Math.min(samples.length, Math.floor(bytes / (channels * 2)) * channels);
15
+ return [samples.subarray(0, count), samples.subarray(count)];
16
+ }
@@ -0,0 +1,109 @@
1
+ import { floatToS16 } from "./pcm.js";
2
+
3
+ // LazyI piano's sound-producing keyboard rows. The table
4
+ // is relative to Space = MIDI 60 (C4). Change PIANO_DO alone to transpose
5
+ // every key; the default A4 exactly matches LazyI's original octave.
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.
9
+ export const PIANO_KEYS = Object.freeze({
10
+ F1: 29, F2: 36, F3: 41, F4: 45, F5: 48, F6: 53,
11
+ F7: 81, F8: 83, F9: 84, F10: 86, F11: 88, F12: 89,
12
+ "`": 33, "1": 36, "2": 43, "3": 48, "4": 52, "5": 55, "6": 49,
13
+ "7": 74, "8": 76, "9": 77, "0": 79, "-": 81, "=": 83,
14
+ q: 31, w: 38, e: 43, r: 47, t: 50, y: 79, u: 69,
15
+ i: 71, o: 72, p: 74, "[": 76, "]": 77, "\\": 79,
16
+ a: 33, s: 40, d: 45, f: 48, g: 52, h: 72, j: 62,
17
+ k: 64, l: 65, ";": 67, "'": 69, Enter: 71,
18
+ z: 28, x: 35, c: 40, v: 43, b: 47, n: 67, m: 57,
19
+ ",": 59, ".": 60, "/": 62, " ": 60,
20
+ });
21
+ export const PIANO_DO = 69; // A4, LazyI's original Space pitch.
22
+
23
+ const CODE_KEYS = {
24
+ Backquote: "`", Minus: "-", Equal: "=", BracketLeft: "[", BracketRight: "]",
25
+ Backslash: "\\", Semicolon: ";", Quote: "'", Comma: ",", Period: ".", Slash: "/",
26
+ Space: " ", NumpadEnter: "Enter",
27
+ };
28
+ const SHIFTED_KEYS = {
29
+ "~": "`", "!": "1", "@": "2", "#": "3", "$": "4", "%": "5", "^": "6",
30
+ "&": "7", "*": "8", "(": "9", ")": "0", _: "-", "+": "=", "{": "[",
31
+ "}": "]", "|": "\\", ":": ";", '"': "'", "<": ",", ">": ".", "?": "/",
32
+ };
33
+
34
+ /** Prefer physical code when present; IME text falls back to event.key/data. */
35
+ export function pianoNote(key, code = "") {
36
+ const physical = /^Key[A-Z]$/.test(code) ? code.slice(3).toLowerCase()
37
+ : /^Digit[0-9]$/.test(code) ? code.slice(5)
38
+ : CODE_KEYS[code] ?? (code.startsWith("F") ? code : null);
39
+ const typed = key.length === 1 ? (SHIFTED_KEYS[key] ?? key.toLowerCase())
40
+ : key === "Space" || key === "Spacebar" ? " " : key;
41
+ const note = PIANO_KEYS[physical ?? typed];
42
+ return note === undefined ? null : note + PIANO_DO - 60;
43
+ }
44
+
45
+ /** A short struck-string tone, mono 48 kHz s16le PCM. */
46
+ export function pianoTone(note, rate = 48000) {
47
+ const frames = Math.round(rate * 0.7);
48
+ const samples = new Float32Array(frames);
49
+ const frequency = 440 * 2 ** ((note - 69) / 12);
50
+ // The original left-hand register is quiet on small speakers. Raise only
51
+ // its level; leave pitch and relative harmonic balance untouched.
52
+ const gain = 1 + Math.max(0, Math.min(1, (60 - note) / 24));
53
+ // Piano strings are slightly stiff and two strings of a note beat softly.
54
+ // These are partials of the requested pitch, not a transposition.
55
+ const partials = [1, 0.32, 0.13, 0.055, 0.025];
56
+ const phases = partials.map((_, index) => {
57
+ const harmonic = index + 1;
58
+ const stretched = harmonic * Math.sqrt(1 + 0.00015 * harmonic * harmonic);
59
+ return 2 * Math.PI * frequency * stretched;
60
+ });
61
+ for (let index = 0; index < frames; index++) {
62
+ const time = index / rate;
63
+ const attack = Math.min(1, time * 320);
64
+ const release = Math.min(1, (frames - index) / (rate * 0.035));
65
+ let sound = 0;
66
+ for (let partial = 0; partial < partials.length; partial++) {
67
+ const decay = Math.exp(-(3.5 + partial * 1.2) * time);
68
+ const phase = phases[partial] * time;
69
+ sound += partials[partial] * decay * (
70
+ 0.82 * Math.sin(phase) + 0.18 * Math.sin(phase * 1.0017)
71
+ );
72
+ }
73
+ samples[index] = 0.28 * gain * attack * release * sound;
74
+ }
75
+ return floatToS16(samples);
76
+ }
77
+
78
+ /** Mixes all piano notes into one persistent mono PCM stream. */
79
+ export class PianoMixer {
80
+ #cache = new Map();
81
+ #voices = [];
82
+
83
+ trigger(note) {
84
+ let tone = this.#cache.get(note);
85
+ if (!tone) {
86
+ const bytes = pianoTone(note);
87
+ tone = new Int16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 2);
88
+ this.#cache.set(note, tone);
89
+ }
90
+ this.#voices.push({ tone, position: 0 });
91
+ if (this.#voices.length > 16) this.#voices.shift();
92
+ }
93
+
94
+ render(bytes) {
95
+ const frames = Math.max(0, Math.floor(bytes / 2));
96
+ const output = new Uint8Array(frames * 2);
97
+ const view = new DataView(output.buffer);
98
+ for (let frame = 0; frame < frames; frame++) {
99
+ let mixed = 0;
100
+ for (const voice of this.#voices) {
101
+ if (voice.position < voice.tone.length) mixed += voice.tone[voice.position++];
102
+ }
103
+ // Soft limiting avoids crackles when several struck notes overlap.
104
+ view.setInt16(frame * 2, Math.round(32767 * Math.tanh(mixed / 32768)), true);
105
+ }
106
+ this.#voices = this.#voices.filter((voice) => voice.position < voice.tone.length);
107
+ return output;
108
+ }
109
+ }