buninu-browser 0.1.0 → 0.2.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.
- package/AGENTS.md +17 -0
- package/LICENSES/brotli.txt +19 -0
- package/README.md +41 -7
- package/THIRD_PARTY_NOTICES.md +26 -4
- package/buninu-browser.js +10 -2
- package/lib/audio/pcm.js +16 -0
- package/lib/audio/piano.js +109 -0
- package/lib/audio/pulse-client.js +413 -0
- package/lib/cdp-server.js +28 -7
- package/lib/document-types.js +308 -0
- package/lib/download-directory.js +28 -0
- package/lib/download.js +63 -0
- package/lib/happy-dom/selector-matcher.js +83 -0
- package/lib/headless-shell.js +165 -26
- package/lib/input/hit-test.js +57 -8
- package/lib/layout/text-layout.js +290 -41
- package/lib/network/network-service.js +177 -40
- package/lib/network/resolver.js +16 -0
- package/lib/paint/display-list.js +25 -10
- package/lib/render-tree/index.js +58 -0
- package/lib/renderer/bindings.js +543 -55
- package/lib/renderer/flags.js +8 -5
- package/lib/renderer/host.js +103 -7
- package/lib/renderer/main.js +35 -3
- package/lib/renderer/module-transform.js +30 -23
- package/lib/renderer/page-realm.js +228 -24
- package/lib/renderer/page-renderer.js +1264 -221
- package/lib/screenshot-cli.js +61 -0
- package/lib/style/computed-style.js +558 -229
- package/lib/style/selector-keys.js +302 -0
- package/lib/style/selector-traits.js +240 -0
- package/lib/user-agent.js +12 -0
- package/package.json +1 -1
- package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
- package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
- package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
- package/patches/happy-dom/README.md +12 -0
- package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
- package/patches/ses/README.md +16 -0
- package/test/computed-style.test.js +113 -1
- package/test/document-types.test.js +111 -0
- package/test/download.test.js +192 -0
- package/test/fixtures/tone.mp3 +0 -0
- package/test/flex.test.js +11 -0
- package/test/happy-dom-matches.test.js +25 -0
- package/test/headless-shell.test.js +56 -0
- package/test/hit-test.test.js +37 -0
- package/test/log.test.js +4 -1
- package/test/network-service.test.js +67 -0
- package/test/page-scripts.test.js +437 -29
- package/test/piano.test.js +65 -0
- package/test/positioning.test.js +11 -0
- package/test/pulse-client.test.js +130 -0
- package/test/render-tree.test.js +36 -1
- package/test/resolver.test.js +12 -1
- package/test/screenshot-cli.test.js +19 -0
- package/test/selector-keys.test.js +39 -0
- package/test/selector-traits.test.js +19 -0
- package/test/text-layout.test.js +91 -1
- package/test/user-agent.test.js +42 -0
- package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
- 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
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
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 |
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
|
package/lib/audio/pcm.js
ADDED
|
@@ -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
|
+
}
|