@driftengine/native-host 4.0.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.
- package/LICENSE +202 -0
- package/NOTICE +29 -0
- package/README.md +58 -0
- package/dist/activation.d.ts +26 -0
- package/dist/activation.js +38 -0
- package/dist/alphaCopy.d.ts +36 -0
- package/dist/alphaCopy.js +178 -0
- package/dist/audio.d.ts +55 -0
- package/dist/audio.js +193 -0
- package/dist/audioParams.d.ts +25 -0
- package/dist/audioParams.js +128 -0
- package/dist/bridge.d.ts +70 -0
- package/dist/bridge.js +170 -0
- package/dist/canvas.d.ts +97 -0
- package/dist/canvas.js +197 -0
- package/dist/canvasStyle.d.ts +23 -0
- package/dist/canvasStyle.js +79 -0
- package/dist/device.d.ts +34 -0
- package/dist/device.js +144 -0
- package/dist/dialogs.d.ts +23 -0
- package/dist/dialogs.js +85 -0
- package/dist/domEvents.d.ts +37 -0
- package/dist/domEvents.js +145 -0
- package/dist/domTree.d.ts +34 -0
- package/dist/domTree.js +194 -0
- package/dist/dualRumble.d.ts +19 -0
- package/dist/dualRumble.js +47 -0
- package/dist/eventClasses.d.ts +12 -0
- package/dist/eventClasses.js +35 -0
- package/dist/files.d.ts +16 -0
- package/dist/files.js +59 -0
- package/dist/fullscreen.d.ts +33 -0
- package/dist/fullscreen.js +65 -0
- package/dist/game.d.ts +25 -0
- package/dist/game.js +38 -0
- package/dist/gamepads.d.ts +65 -0
- package/dist/gamepads.js +178 -0
- package/dist/globals.d.ts +24 -0
- package/dist/globals.js +37 -0
- package/dist/gpu.d.ts +26 -0
- package/dist/gpu.js +47 -0
- package/dist/images.d.ts +50 -0
- package/dist/images.js +111 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +21 -0
- package/dist/keyCodes.d.ts +30 -0
- package/dist/keyCodes.js +205 -0
- package/dist/keyEvents.d.ts +54 -0
- package/dist/keyEvents.js +117 -0
- package/dist/mediaQueries.d.ts +62 -0
- package/dist/mediaQueries.js +152 -0
- package/dist/mouseEvents.d.ts +96 -0
- package/dist/mouseEvents.js +287 -0
- package/dist/page.d.ts +118 -0
- package/dist/page.js +302 -0
- package/dist/pointerCapture.d.ts +32 -0
- package/dist/pointerCapture.js +69 -0
- package/dist/present.d.ts +27 -0
- package/dist/present.js +83 -0
- package/dist/rawPad.d.ts +44 -0
- package/dist/rawPad.js +65 -0
- package/dist/readback.d.ts +18 -0
- package/dist/readback.js +46 -0
- package/dist/runtime.d.ts +69 -0
- package/dist/runtime.js +103 -0
- package/dist/standardPad.d.ts +49 -0
- package/dist/standardPad.js +80 -0
- package/dist/textHost.d.ts +37 -0
- package/dist/textHost.js +43 -0
- package/dist/threads.d.ts +27 -0
- package/dist/threads.js +59 -0
- package/dist/tileFiles.d.ts +16 -0
- package/dist/tileFiles.js +36 -0
- package/dist/touchEvents.d.ts +67 -0
- package/dist/touchEvents.js +210 -0
- package/dist/uiEvents.d.ts +157 -0
- package/dist/uiEvents.js +206 -0
- package/dist/uiTouch.d.ts +59 -0
- package/dist/uiTouch.js +75 -0
- package/dist/window.d.ts +71 -0
- package/dist/window.js +164 -0
- package/dist/windowMode.d.ts +19 -0
- package/dist/windowMode.js +15 -0
- package/dist/windowTitle.d.ts +18 -0
- package/dist/windowTitle.js +33 -0
- package/dist/workerScope.mjs +48 -0
- package/licenses/GPL-3.0.txt +674 -0
- package/package.json +83 -0
- package/src/activation.ts +42 -0
- package/src/alphaCopy.ts +201 -0
- package/src/audio.ts +246 -0
- package/src/audioParams.ts +150 -0
- package/src/bridge.ts +230 -0
- package/src/canvas.ts +227 -0
- package/src/canvasStyle.ts +89 -0
- package/src/device.ts +167 -0
- package/src/dialogs.ts +98 -0
- package/src/domEvents.ts +170 -0
- package/src/domTree.ts +215 -0
- package/src/drs.d.ts +9 -0
- package/src/dualRumble.ts +58 -0
- package/src/eventClasses.ts +46 -0
- package/src/files.ts +61 -0
- package/src/fixtures/grey.jpg +0 -0
- package/src/fixtures/rgb.png +0 -0
- package/src/fixtures/tone.opus +0 -0
- package/src/fullscreen.ts +71 -0
- package/src/game.ts +53 -0
- package/src/gamepads.ts +195 -0
- package/src/globals.ts +39 -0
- package/src/gpu.ts +59 -0
- package/src/images.ts +148 -0
- package/src/index.ts +24 -0
- package/src/keyCodes.ts +203 -0
- package/src/keyEvents.ts +135 -0
- package/src/mediaQueries.ts +173 -0
- package/src/mouseEvents.ts +328 -0
- package/src/page.ts +337 -0
- package/src/pointerCapture.ts +73 -0
- package/src/present.ts +92 -0
- package/src/rawPad.ts +85 -0
- package/src/readback.ts +61 -0
- package/src/runtime.ts +162 -0
- package/src/standardPad.ts +126 -0
- package/src/textHost.ts +66 -0
- package/src/threads.ts +66 -0
- package/src/tileFiles.ts +39 -0
- package/src/touchEvents.ts +238 -0
- package/src/uiEvents.ts +268 -0
- package/src/uiTouch.ts +97 -0
- package/src/window.ts +192 -0
- package/src/windowMode.ts +24 -0
- package/src/windowTitle.ts +36 -0
- package/src/workerScope.mjs +48 -0
package/package.json
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@driftengine/native-host",
|
|
3
|
+
"version": "4.0.0",
|
|
4
|
+
"description": "The engine on a native window and a native WebGPU device, with no browser between them",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"drift-source": "./src/index.ts",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json",
|
|
16
|
+
"./*": "./*"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"!src/**/*.test.mjs",
|
|
23
|
+
"!src/**/__snapshots__",
|
|
24
|
+
"licenses",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"NOTICE"
|
|
28
|
+
],
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@jsquash/jpeg": "1.6.0",
|
|
32
|
+
"ogg-opus-decoder": "1.7.5"
|
|
33
|
+
},
|
|
34
|
+
"optionalDependencies": {
|
|
35
|
+
"@kmamal/gpu": "0.2.0",
|
|
36
|
+
"@kmamal/sdl": "0.11.13",
|
|
37
|
+
"node-web-audio-api": "2.2.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@driftengine/audio": "4.0.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@driftengine/core": "4.0.0",
|
|
44
|
+
"@driftengine/package": "4.0.0",
|
|
45
|
+
"@driftengine/texture": "4.0.0",
|
|
46
|
+
"@driftengine/ui2d": "4.0.0"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"@driftengine/texture": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"@driftengine/ui2d": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"author": "Drift Technologies",
|
|
57
|
+
"repository": {
|
|
58
|
+
"type": "git",
|
|
59
|
+
"url": "git+https://github.com/drftrun/driftengine.git",
|
|
60
|
+
"directory": "packages/native-host"
|
|
61
|
+
},
|
|
62
|
+
"homepage": "https://github.com/drftrun/driftengine#readme",
|
|
63
|
+
"bugs": "https://github.com/drftrun/driftengine/issues",
|
|
64
|
+
"keywords": [
|
|
65
|
+
"driftengine",
|
|
66
|
+
"3d",
|
|
67
|
+
"webgpu",
|
|
68
|
+
"typescript",
|
|
69
|
+
"native",
|
|
70
|
+
"desktop",
|
|
71
|
+
"dawn",
|
|
72
|
+
"sdl"
|
|
73
|
+
],
|
|
74
|
+
"engines": {
|
|
75
|
+
"node": ">=22.12.0"
|
|
76
|
+
},
|
|
77
|
+
"publishConfig": {
|
|
78
|
+
"access": "public"
|
|
79
|
+
},
|
|
80
|
+
"scripts": {
|
|
81
|
+
"prepack": "node ../../scripts/build.mjs @driftengine/native-host"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a person has just done something, as a browser's transient activation answers it — the
|
|
3
|
+
* gate on what a page may do only in answer to a person: pointer lock, fullscreen.
|
|
4
|
+
*
|
|
5
|
+
* **Chrome's rules, measured 2026-09-19 in Chrome 151 over the DevTools protocol.** A key down
|
|
6
|
+
* other than Escape, a mouse press or a finger lifted makes a page active for five seconds: a
|
|
7
|
+
* request 4.5 s after an unused click was granted and one at 5.5 s refused. Fullscreen spends it —
|
|
8
|
+
* a second request 100 ms after a granted one was refused — and pointer lock does not: a lock and
|
|
9
|
+
* then fullscreen from one click were both granted. The input is noted before its event is sent,
|
|
10
|
+
* so a listener for that very event may already ask.
|
|
11
|
+
*
|
|
12
|
+
* What it gives up: a script's own `dispatchEvent` is never a gesture, as in a browser, so input a
|
|
13
|
+
* capture replays goes through SDL's events (`HostWindow.replay`) to count as one.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const ACTIVE_MS = 5000;
|
|
17
|
+
|
|
18
|
+
export class UserActivation {
|
|
19
|
+
private at = Number.NEGATIVE_INFINITY;
|
|
20
|
+
private ever = false;
|
|
21
|
+
|
|
22
|
+
constructor(private readonly now: () => number) {}
|
|
23
|
+
|
|
24
|
+
/** A person did something: a key down, a mouse press, a finger lifted. */
|
|
25
|
+
notify(): void {
|
|
26
|
+
this.at = this.now();
|
|
27
|
+
this.ever = true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Spent, by an ask that takes the gesture with it. */
|
|
31
|
+
consume(): void {
|
|
32
|
+
this.at = Number.NEGATIVE_INFINITY;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get isActive(): boolean {
|
|
36
|
+
return this.now() - this.at < ACTIVE_MS;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get hasBeenActive(): boolean {
|
|
40
|
+
return this.ever;
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/alphaCopy.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The alpha step of a browser's image copy, done where the browser does it: on the device.
|
|
3
|
+
*
|
|
4
|
+
* **Chrome converts alpha in a copy with a shader**, Dawn's `CopyTextureForBrowser`: it divides a
|
|
5
|
+
* premultiplied image's colour by its alpha, or multiplies a straight one's, in single precision on
|
|
6
|
+
* the GPU, and into an sRGB texture it decodes the result first so the write's own encode lands on
|
|
7
|
+
* it. This GPU's division is not correctly rounded, so no arithmetic on the CPU gives the same
|
|
8
|
+
* bytes: a rounded `c · 255 / a` differed from Chrome on 4,299 of 65,024 (colour, alpha) pairs.
|
|
9
|
+
* The same steps run here, in the same order and precision, on the same device.
|
|
10
|
+
*
|
|
11
|
+
* Measured 2026-09-19 against Chrome on this machine, for every colour at every alpha: the
|
|
12
|
+
* divide and the multiply, into `rgba8unorm` and into `rgba8unorm-srgb` — see `device.test.ts` for
|
|
13
|
+
* the pairs held as literals.
|
|
14
|
+
*
|
|
15
|
+
* **What it gives up: the bytes are this GPU's.** Another GPU divides differently, and so does
|
|
16
|
+
* Chrome on it, so the claim is "what Chrome writes on the machine the host runs on", which is
|
|
17
|
+
* the only thing a pixel gate on that machine can compare.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Which conversion a copy needs. */
|
|
21
|
+
export type AlphaStep = 'unpremultiply' | 'premultiply';
|
|
22
|
+
|
|
23
|
+
const STEP_WGSL = /* wgsl */ `
|
|
24
|
+
struct Params {
|
|
25
|
+
origin: vec2i,
|
|
26
|
+
steps: u32,
|
|
27
|
+
g: f32, a: f32, b: f32, c: f32, d: f32, e: f32, f: f32,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@group(0) @binding(0) var source: texture_2d<f32>;
|
|
31
|
+
@group(0) @binding(1) var<uniform> params: Params;
|
|
32
|
+
|
|
33
|
+
@vertex
|
|
34
|
+
fn vertexMain(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f {
|
|
35
|
+
let x = f32((index << 1u) & 2u);
|
|
36
|
+
let y = f32(index & 2u);
|
|
37
|
+
return vec4f(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* Dawn's parametric transfer function, as its copy shader writes it. */
|
|
41
|
+
fn transfer(v: f32) -> f32 {
|
|
42
|
+
let absV = abs(v);
|
|
43
|
+
let sgn = sign(v);
|
|
44
|
+
if (absV < params.d) {
|
|
45
|
+
return sgn * (params.c * absV + params.f);
|
|
46
|
+
}
|
|
47
|
+
return sgn * (pow(params.a * absV + params.b, params.g) + params.e);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@fragment
|
|
51
|
+
fn fragmentMain(@builtin(position) position: vec4f) -> @location(0) vec4f {
|
|
52
|
+
var color = textureLoad(source, vec2i(position.xy) - params.origin, 0);
|
|
53
|
+
if ((params.steps & 1u) != 0u) {
|
|
54
|
+
if (color.a != 0.0) {
|
|
55
|
+
color = vec4f(color.rgb / color.a, color.a);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if ((params.steps & 2u) != 0u) {
|
|
59
|
+
color = vec4f(color.rgb * color.a, color.a);
|
|
60
|
+
}
|
|
61
|
+
if ((params.steps & 4u) != 0u) {
|
|
62
|
+
color = vec4f(transfer(color.r), transfer(color.g), transfer(color.b), color.a);
|
|
63
|
+
}
|
|
64
|
+
return color;
|
|
65
|
+
}
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
/* Usage flags, named here rather than read off a global a host may not have installed. */
|
|
69
|
+
const COPY_DST = 0x2;
|
|
70
|
+
const TEXTURE_BINDING = 0x4;
|
|
71
|
+
const BUFFER_COPY_DST = 0x8;
|
|
72
|
+
const UNIFORM = 0x40;
|
|
73
|
+
|
|
74
|
+
const UNPREMULTIPLY = 1;
|
|
75
|
+
const PREMULTIPLY = 2;
|
|
76
|
+
const DECODE_FOR_SRGB = 4;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The sRGB decode's parameters, as Dawn passes them: computed in double and stored as float, which
|
|
80
|
+
* is what a `Float32Array` does with the same expressions.
|
|
81
|
+
*/
|
|
82
|
+
const SRGB_DECODE = [2.4, 1 / 1.055, 0.055 / 1.055, 1 / 12.92, 0.04045, 0, 0];
|
|
83
|
+
|
|
84
|
+
/** 48 bytes: the struct is 40, and a uniform binding is sized in sixteens. */
|
|
85
|
+
const PARAMS_BYTES = 48;
|
|
86
|
+
|
|
87
|
+
export class AlphaCopier {
|
|
88
|
+
private readonly device: GPUDevice;
|
|
89
|
+
private readonly pipelines = new Map<GPUTextureFormat, GPURenderPipeline>();
|
|
90
|
+
private module: GPUShaderModule | null = null;
|
|
91
|
+
|
|
92
|
+
constructor(device: GPUDevice) {
|
|
93
|
+
this.device = device;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Write an image through `step` into `destination`: `bytes` are four a texel, `imageWidth` wide,
|
|
98
|
+
* and the top-left `width` by `height` of them is what is copied.
|
|
99
|
+
*
|
|
100
|
+
* The destination must allow rendering into it, which a copy's destination already must
|
|
101
|
+
* (`copyExternalImageToTexture` asks for `RENDER_ATTACHMENT`). Submitted here, so it is ordered
|
|
102
|
+
* on the queue where the copy it stands in for was called.
|
|
103
|
+
*/
|
|
104
|
+
copy(
|
|
105
|
+
bytes: Uint8Array,
|
|
106
|
+
imageWidth: number,
|
|
107
|
+
imageHeight: number,
|
|
108
|
+
step: AlphaStep,
|
|
109
|
+
destination: GPUCopyExternalImageDestInfo,
|
|
110
|
+
width: number,
|
|
111
|
+
height: number,
|
|
112
|
+
): void {
|
|
113
|
+
const device = this.device;
|
|
114
|
+
const target = destination.texture;
|
|
115
|
+
const format = target.format;
|
|
116
|
+
const origin = destination.origin as GPUOrigin3DDict | undefined;
|
|
117
|
+
const x = origin?.x ?? 0;
|
|
118
|
+
const y = origin?.y ?? 0;
|
|
119
|
+
|
|
120
|
+
const source = device.createTexture({
|
|
121
|
+
label: 'native alpha copy',
|
|
122
|
+
size: [imageWidth, imageHeight],
|
|
123
|
+
format: 'rgba8unorm',
|
|
124
|
+
usage: COPY_DST | TEXTURE_BINDING,
|
|
125
|
+
});
|
|
126
|
+
device.queue.writeTexture({ texture: source }, bytes, { bytesPerRow: imageWidth * 4 }, [
|
|
127
|
+
imageWidth,
|
|
128
|
+
imageHeight,
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
const params = new ArrayBuffer(PARAMS_BYTES);
|
|
132
|
+
new Int32Array(params, 0, 2).set([x, y]);
|
|
133
|
+
const steps =
|
|
134
|
+
(step === 'unpremultiply' ? UNPREMULTIPLY : PREMULTIPLY) |
|
|
135
|
+
(format.endsWith('-srgb') ? DECODE_FOR_SRGB : 0);
|
|
136
|
+
new Uint32Array(params, 8, 1)[0] = steps;
|
|
137
|
+
new Float32Array(params, 12, 7).set(SRGB_DECODE);
|
|
138
|
+
const uniforms = device.createBuffer({
|
|
139
|
+
label: 'native alpha copy',
|
|
140
|
+
size: PARAMS_BYTES,
|
|
141
|
+
usage: UNIFORM | BUFFER_COPY_DST,
|
|
142
|
+
});
|
|
143
|
+
device.queue.writeBuffer(uniforms, 0, params);
|
|
144
|
+
|
|
145
|
+
const pipeline = this.pipelineFor(format);
|
|
146
|
+
const group = device.createBindGroup({
|
|
147
|
+
label: 'native alpha copy',
|
|
148
|
+
layout: pipeline.getBindGroupLayout(0),
|
|
149
|
+
entries: [
|
|
150
|
+
{ binding: 0, resource: source.createView() },
|
|
151
|
+
{ binding: 1, resource: { buffer: uniforms } },
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
const encoder = device.createCommandEncoder({ label: 'native alpha copy' });
|
|
155
|
+
const pass = encoder.beginRenderPass({
|
|
156
|
+
label: 'native alpha copy',
|
|
157
|
+
colorAttachments: [
|
|
158
|
+
{
|
|
159
|
+
view: target.createView({
|
|
160
|
+
dimension: '2d',
|
|
161
|
+
baseMipLevel: destination.mipLevel ?? 0,
|
|
162
|
+
mipLevelCount: 1,
|
|
163
|
+
baseArrayLayer: origin?.z ?? 0,
|
|
164
|
+
arrayLayerCount: 1,
|
|
165
|
+
}),
|
|
166
|
+
/* Kept, because the copy covers a rectangle and the rest of the level is not its own. */
|
|
167
|
+
loadOp: 'load',
|
|
168
|
+
storeOp: 'store',
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
pass.setPipeline(pipeline);
|
|
173
|
+
pass.setBindGroup(0, group);
|
|
174
|
+
pass.setViewport(x, y, width, height, 0, 1);
|
|
175
|
+
pass.setScissorRect(x, y, width, height);
|
|
176
|
+
pass.draw(3);
|
|
177
|
+
pass.end();
|
|
178
|
+
device.queue.submit([encoder.finish()]);
|
|
179
|
+
/* Released once the work that reads them is done; Dawn's binding asserts on a release under
|
|
180
|
+
work in flight. */
|
|
181
|
+
void device.queue.onSubmittedWorkDone().then(() => {
|
|
182
|
+
source.destroy();
|
|
183
|
+
uniforms.destroy();
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private pipelineFor(format: GPUTextureFormat): GPURenderPipeline {
|
|
188
|
+
const held = this.pipelines.get(format);
|
|
189
|
+
if (held !== undefined) return held;
|
|
190
|
+
this.module ??= this.device.createShaderModule({ label: 'native alpha copy', code: STEP_WGSL });
|
|
191
|
+
const pipeline = this.device.createRenderPipeline({
|
|
192
|
+
label: 'native alpha copy',
|
|
193
|
+
layout: 'auto',
|
|
194
|
+
vertex: { module: this.module, entryPoint: 'vertexMain' },
|
|
195
|
+
fragment: { module: this.module, entryPoint: 'fragmentMain', targets: [{ format }] },
|
|
196
|
+
primitive: { topology: 'triangle-list' },
|
|
197
|
+
});
|
|
198
|
+
this.pipelines.set(format, pipeline);
|
|
199
|
+
return pipeline;
|
|
200
|
+
}
|
|
201
|
+
}
|
package/src/audio.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Audio for a host with no browser: the Rust engine's contexts, with Opus decoded where it
|
|
3
|
+
* cannot, and underruns counted.
|
|
4
|
+
*
|
|
5
|
+
* **The engine's mix is Web Audio from end to end** — `AudioGraph` and `MixConsole` build nodes on a
|
|
6
|
+
* context and never touch a device — so the host's job is a Web Audio implementation, not a mixer.
|
|
7
|
+
* It is `node-web-audio-api` (IRCAM, BSD-3-Clause), `web-audio-api-rs` with prebuilt binaries,
|
|
8
|
+
* which plays through JACK on Linux, and PipeWire provides that. Chosen by the maintainer on
|
|
9
|
+
* 2026-09-19.
|
|
10
|
+
*
|
|
11
|
+
* **Opus is decoded here**, by libopus as WebAssembly (`ogg-opus-decoder`, MIT), because the Rust
|
|
12
|
+
* engine's decoder refuses it (measured: "unsupported audio codec") and Opus is the first format the
|
|
13
|
+
* engine's sound registry looks for. Anything else goes to the Rust engine's own decoder. A decode
|
|
14
|
+
* comes back at the context's sample rate, as the specification's `decodeAudioData` does: Opus is
|
|
15
|
+
* always 48 kHz, so a context at another rate has it resampled, by the same engine rendering it
|
|
16
|
+
* offline.
|
|
17
|
+
*
|
|
18
|
+
* **Sample rate and buffer size are the host's to ask for** — `sampleRate` and `latencyHint`, given
|
|
19
|
+
* to every context the engine creates, and what the device gave is reported once it runs; under
|
|
20
|
+
* JACK the server sets the buffer, so the latency asked for is a request. **An underrun is counted**
|
|
21
|
+
* from the context's `renderCapacity` and said once, rather than heard as a click and never
|
|
22
|
+
* reported.
|
|
23
|
+
*
|
|
24
|
+
* What it gives up: a browser's autoplay rule, which has no meaning here, so a context runs from
|
|
25
|
+
* the moment it is made; and the decoders' sample-for-sample agreement with Chrome's, which no
|
|
26
|
+
* gate compares yet.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { createRequire } from 'node:module';
|
|
30
|
+
|
|
31
|
+
import { OggOpusDecoder } from 'ogg-opus-decoder';
|
|
32
|
+
|
|
33
|
+
import { correctAutomation } from './audioParams.ts';
|
|
34
|
+
|
|
35
|
+
type WebAudioModule = {
|
|
36
|
+
AudioContext: typeof AudioContext;
|
|
37
|
+
OfflineAudioContext: typeof OfflineAudioContext;
|
|
38
|
+
AudioBuffer: typeof AudioBuffer;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/* A native module published as CommonJS. */
|
|
42
|
+
const webAudio = createRequire(import.meta.url)('node-web-audio-api') as WebAudioModule;
|
|
43
|
+
|
|
44
|
+
export interface AudioDefaults {
|
|
45
|
+
readonly sampleRate?: number;
|
|
46
|
+
/** A category, or seconds of buffer. */
|
|
47
|
+
readonly latencyHint?: AudioContextLatencyCategory | number;
|
|
48
|
+
/** `none` for a context with no output device: a check, a CI machine. */
|
|
49
|
+
readonly sink?: 'default' | 'none';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** An Ogg stream whose first page carries an Opus header. */
|
|
53
|
+
export function isOggOpus(bytes: Uint8Array): boolean {
|
|
54
|
+
const text = (at: number, length: number) =>
|
|
55
|
+
String.fromCharCode(...bytes.subarray(at, at + length));
|
|
56
|
+
return bytes.length > 36 && text(0, 4) === 'OggS' && text(28, 8) === 'OpusHead';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* How many samples an Ogg Opus stream holds: its last page's granule position less the header's
|
|
61
|
+
* pre-skip. The decoder drops the pre-skip and hands back the last packet whole, so it runs long;
|
|
62
|
+
* a browser's decode ends where the stream says it does. Measured on the fixture: 24,312 decoded,
|
|
63
|
+
* 24,000 kept, and those are Chrome's samples at no offset, to within 9.5e-6 — two builds of libopus
|
|
64
|
+
* rounding differently, about −100 dB.
|
|
65
|
+
*/
|
|
66
|
+
function opusLength(bytes: Uint8Array): number {
|
|
67
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
68
|
+
let granule = 0;
|
|
69
|
+
for (let at = 0; at + 27 <= bytes.length && view.getUint32(at) === 0x4f676753;) {
|
|
70
|
+
granule = Number(view.getBigInt64(at + 6, true));
|
|
71
|
+
const segments = bytes[at + 26] as number;
|
|
72
|
+
let length = 0;
|
|
73
|
+
for (let s = 0; s < segments; s += 1) length += bytes[at + 27 + s] as number;
|
|
74
|
+
at += 27 + segments + length;
|
|
75
|
+
}
|
|
76
|
+
/* The header is the first page's packet, after its 27 bytes and its segment table. */
|
|
77
|
+
const preSkip = view.getUint16(27 + (bytes[26] as number) + 10, true);
|
|
78
|
+
return Math.max(0, granule - preSkip);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Opus to an `AudioBuffer` at `target`'s rate. */
|
|
82
|
+
async function decodeOpus(bytes: Uint8Array, target: BaseAudioContext): Promise<AudioBuffer> {
|
|
83
|
+
const decoder = new OggOpusDecoder();
|
|
84
|
+
await decoder.ready;
|
|
85
|
+
try {
|
|
86
|
+
const decoded = await decoder.decodeFile(bytes);
|
|
87
|
+
if (decoded.errors.length > 0) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`[driftengine] the Opus stream did not decode: ${decoded.errors[0]?.message ?? ''}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const channels = decoded.channelData.length;
|
|
93
|
+
const frames = Math.min(decoded.samplesDecoded, opusLength(bytes));
|
|
94
|
+
const at48 = new webAudio.AudioBuffer({
|
|
95
|
+
numberOfChannels: channels,
|
|
96
|
+
length: frames,
|
|
97
|
+
sampleRate: decoded.sampleRate,
|
|
98
|
+
});
|
|
99
|
+
decoded.channelData.forEach((samples, channel) =>
|
|
100
|
+
at48.copyToChannel(samples.subarray(0, frames) as Float32Array<ArrayBuffer>, channel),
|
|
101
|
+
);
|
|
102
|
+
if (decoded.sampleRate === target.sampleRate) return at48;
|
|
103
|
+
/*
|
|
104
|
+
* The length as Chrome works it out — divided by the ratio of the rates, truncated — which
|
|
105
|
+
* makes 24,000 samples at 48 kHz 22,049 at 44.1 kHz rather than 22,050, because the ratio is
|
|
106
|
+
* not exact in a double. Measured in Chrome's own decode of the fixture.
|
|
107
|
+
*/
|
|
108
|
+
const length = Math.floor(frames / (decoded.sampleRate / target.sampleRate));
|
|
109
|
+
const resampler = new webAudio.OfflineAudioContext(channels, length, target.sampleRate);
|
|
110
|
+
const source = resampler.createBufferSource();
|
|
111
|
+
source.buffer = at48;
|
|
112
|
+
source.connect(resampler.destination);
|
|
113
|
+
source.start();
|
|
114
|
+
return await resampler.startRendering();
|
|
115
|
+
} finally {
|
|
116
|
+
decoder.free();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `decodeAudioData`, in both of its shapes: a promise, and the two callbacks. */
|
|
121
|
+
function decodeWith(
|
|
122
|
+
context: BaseAudioContext,
|
|
123
|
+
engine: (data: ArrayBuffer) => Promise<AudioBuffer>,
|
|
124
|
+
data: ArrayBuffer,
|
|
125
|
+
success?: DecodeSuccessCallback | null,
|
|
126
|
+
failure?: DecodeErrorCallback | null,
|
|
127
|
+
): Promise<AudioBuffer> {
|
|
128
|
+
const bytes = new Uint8Array(data);
|
|
129
|
+
const decoding = isOggOpus(bytes) ? decodeOpus(bytes, context) : engine(data);
|
|
130
|
+
return decoding.then(
|
|
131
|
+
(buffer) => {
|
|
132
|
+
success?.(buffer);
|
|
133
|
+
return buffer;
|
|
134
|
+
},
|
|
135
|
+
(error: unknown) => {
|
|
136
|
+
failure?.(error as DOMException);
|
|
137
|
+
throw error;
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Watches a context's render capacity and counts the updates in which it could not keep up. */
|
|
143
|
+
export class UnderrunCounter {
|
|
144
|
+
underruns = 0;
|
|
145
|
+
|
|
146
|
+
constructor(capacity: EventTarget & { start(options?: { updateInterval?: number }): void }) {
|
|
147
|
+
capacity.addEventListener('update', (event) => {
|
|
148
|
+
if (((event as Event & { underrunRatio?: number }).underrunRatio ?? 0) <= 0) return;
|
|
149
|
+
this.underruns += 1;
|
|
150
|
+
if (this.underruns === 1) {
|
|
151
|
+
console.warn(
|
|
152
|
+
'[driftengine] audio underran: the output could not be fed in time. Counted from here on.',
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
capacity.start({ updateInterval: 1 });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The two context classes a page has, over the Rust engine, with the host's defaults. */
|
|
161
|
+
export function hostAudioContexts(defaults: AudioDefaults = {}): WebAudioModule {
|
|
162
|
+
/* The Rust engine's scheduled ramps, corrected to Chrome's (`audioParams.ts`). */
|
|
163
|
+
correctAutomation();
|
|
164
|
+
const options = {
|
|
165
|
+
...(defaults.sampleRate === undefined ? {} : { sampleRate: defaults.sampleRate }),
|
|
166
|
+
...(defaults.latencyHint === undefined ? {} : { latencyHint: defaults.latencyHint }),
|
|
167
|
+
...(defaults.sink === 'none' ? { sinkId: { type: 'none' } } : {}),
|
|
168
|
+
};
|
|
169
|
+
class HostAudioContext extends webAudio.AudioContext {
|
|
170
|
+
readonly underruns: UnderrunCounter | null;
|
|
171
|
+
constructor(asked: AudioContextOptions = {}) {
|
|
172
|
+
super({ ...options, ...asked });
|
|
173
|
+
/*
|
|
174
|
+
* Reported once the device has said what it gave, which it does a few milliseconds after the
|
|
175
|
+
* context starts: under JACK the server sets the buffer, so `latencyHint` is a request
|
|
176
|
+
* (measured here: 21 ms for either category, PipeWire's quantum). Asked for rather than
|
|
177
|
+
* listened for, because starting the render-capacity watch below starts the context before
|
|
178
|
+
* a `statechange` listener could hear it.
|
|
179
|
+
*/
|
|
180
|
+
if (defaults.sink !== 'none') {
|
|
181
|
+
let tries = 0;
|
|
182
|
+
const report = (): void => {
|
|
183
|
+
tries += 1;
|
|
184
|
+
if (this.outputLatency <= 0 && tries < 50) {
|
|
185
|
+
setTimeout(report, 20).unref();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const frames = Math.round(this.outputLatency * this.sampleRate);
|
|
189
|
+
console.log(
|
|
190
|
+
`[driftengine] audio: ${this.sampleRate} Hz, ${frames} frames of output latency (${(this.outputLatency * 1000).toFixed(1)} ms)`,
|
|
191
|
+
);
|
|
192
|
+
};
|
|
193
|
+
setTimeout(report, 20).unref();
|
|
194
|
+
}
|
|
195
|
+
/* Nothing renders against a clock with no device, so there is nothing to underrun. */
|
|
196
|
+
this.underruns =
|
|
197
|
+
defaults.sink === 'none'
|
|
198
|
+
? null
|
|
199
|
+
: new UnderrunCounter(
|
|
200
|
+
(this as unknown as { renderCapacity: EventTarget & { start(): void } })
|
|
201
|
+
.renderCapacity,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
override decodeAudioData(
|
|
205
|
+
data: ArrayBuffer,
|
|
206
|
+
success?: DecodeSuccessCallback | null,
|
|
207
|
+
failure?: DecodeErrorCallback | null,
|
|
208
|
+
): Promise<AudioBuffer> {
|
|
209
|
+
return decodeWith(this, (bytes) => super.decodeAudioData(bytes), data, success, failure);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
class HostOfflineAudioContext extends webAudio.OfflineAudioContext {
|
|
213
|
+
override decodeAudioData(
|
|
214
|
+
data: ArrayBuffer,
|
|
215
|
+
success?: DecodeSuccessCallback | null,
|
|
216
|
+
failure?: DecodeErrorCallback | null,
|
|
217
|
+
): Promise<AudioBuffer> {
|
|
218
|
+
return decodeWith(this, (bytes) => super.decodeAudioData(bytes), data, success, failure);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
AudioContext: HostAudioContext as unknown as typeof AudioContext,
|
|
223
|
+
OfflineAudioContext: HostOfflineAudioContext as unknown as typeof OfflineAudioContext,
|
|
224
|
+
AudioBuffer: webAudio.AudioBuffer,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Give the page its audio globals, and hand back what takes them away again. */
|
|
229
|
+
export function installAudio(
|
|
230
|
+
defaults: AudioDefaults = {},
|
|
231
|
+
scope: Record<string, unknown> = globalThis as Record<string, unknown>,
|
|
232
|
+
): () => void {
|
|
233
|
+
const contexts = hostAudioContexts(defaults);
|
|
234
|
+
const held = (['AudioContext', 'OfflineAudioContext', 'AudioBuffer'] as const).map(
|
|
235
|
+
(name) => [name, name in scope, scope[name]] as const,
|
|
236
|
+
);
|
|
237
|
+
scope['AudioContext'] = contexts.AudioContext;
|
|
238
|
+
scope['OfflineAudioContext'] = contexts.OfflineAudioContext;
|
|
239
|
+
scope['AudioBuffer'] = contexts.AudioBuffer;
|
|
240
|
+
return () => {
|
|
241
|
+
for (const [name, had, value] of held) {
|
|
242
|
+
if (had) scope[name] = value;
|
|
243
|
+
else delete scope[name];
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|