@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/dist/audio.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
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
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import { OggOpusDecoder } from 'ogg-opus-decoder';
|
|
30
|
+
import { correctAutomation } from './audioParams.js';
|
|
31
|
+
/* A native module published as CommonJS. */
|
|
32
|
+
const webAudio = createRequire(import.meta.url)('node-web-audio-api');
|
|
33
|
+
/** An Ogg stream whose first page carries an Opus header. */
|
|
34
|
+
export function isOggOpus(bytes) {
|
|
35
|
+
const text = (at, length) => String.fromCharCode(...bytes.subarray(at, at + length));
|
|
36
|
+
return bytes.length > 36 && text(0, 4) === 'OggS' && text(28, 8) === 'OpusHead';
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* How many samples an Ogg Opus stream holds: its last page's granule position less the header's
|
|
40
|
+
* pre-skip. The decoder drops the pre-skip and hands back the last packet whole, so it runs long;
|
|
41
|
+
* a browser's decode ends where the stream says it does. Measured on the fixture: 24,312 decoded,
|
|
42
|
+
* 24,000 kept, and those are Chrome's samples at no offset, to within 9.5e-6 — two builds of libopus
|
|
43
|
+
* rounding differently, about −100 dB.
|
|
44
|
+
*/
|
|
45
|
+
function opusLength(bytes) {
|
|
46
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
47
|
+
let granule = 0;
|
|
48
|
+
for (let at = 0; at + 27 <= bytes.length && view.getUint32(at) === 0x4f676753;) {
|
|
49
|
+
granule = Number(view.getBigInt64(at + 6, true));
|
|
50
|
+
const segments = bytes[at + 26];
|
|
51
|
+
let length = 0;
|
|
52
|
+
for (let s = 0; s < segments; s += 1)
|
|
53
|
+
length += bytes[at + 27 + s];
|
|
54
|
+
at += 27 + segments + length;
|
|
55
|
+
}
|
|
56
|
+
/* The header is the first page's packet, after its 27 bytes and its segment table. */
|
|
57
|
+
const preSkip = view.getUint16(27 + bytes[26] + 10, true);
|
|
58
|
+
return Math.max(0, granule - preSkip);
|
|
59
|
+
}
|
|
60
|
+
/** Opus to an `AudioBuffer` at `target`'s rate. */
|
|
61
|
+
async function decodeOpus(bytes, target) {
|
|
62
|
+
const decoder = new OggOpusDecoder();
|
|
63
|
+
await decoder.ready;
|
|
64
|
+
try {
|
|
65
|
+
const decoded = await decoder.decodeFile(bytes);
|
|
66
|
+
if (decoded.errors.length > 0) {
|
|
67
|
+
throw new Error(`[driftengine] the Opus stream did not decode: ${decoded.errors[0]?.message ?? ''}`);
|
|
68
|
+
}
|
|
69
|
+
const channels = decoded.channelData.length;
|
|
70
|
+
const frames = Math.min(decoded.samplesDecoded, opusLength(bytes));
|
|
71
|
+
const at48 = new webAudio.AudioBuffer({
|
|
72
|
+
numberOfChannels: channels,
|
|
73
|
+
length: frames,
|
|
74
|
+
sampleRate: decoded.sampleRate,
|
|
75
|
+
});
|
|
76
|
+
decoded.channelData.forEach((samples, channel) => at48.copyToChannel(samples.subarray(0, frames), channel));
|
|
77
|
+
if (decoded.sampleRate === target.sampleRate)
|
|
78
|
+
return at48;
|
|
79
|
+
/*
|
|
80
|
+
* The length as Chrome works it out — divided by the ratio of the rates, truncated — which
|
|
81
|
+
* makes 24,000 samples at 48 kHz 22,049 at 44.1 kHz rather than 22,050, because the ratio is
|
|
82
|
+
* not exact in a double. Measured in Chrome's own decode of the fixture.
|
|
83
|
+
*/
|
|
84
|
+
const length = Math.floor(frames / (decoded.sampleRate / target.sampleRate));
|
|
85
|
+
const resampler = new webAudio.OfflineAudioContext(channels, length, target.sampleRate);
|
|
86
|
+
const source = resampler.createBufferSource();
|
|
87
|
+
source.buffer = at48;
|
|
88
|
+
source.connect(resampler.destination);
|
|
89
|
+
source.start();
|
|
90
|
+
return await resampler.startRendering();
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
decoder.free();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** `decodeAudioData`, in both of its shapes: a promise, and the two callbacks. */
|
|
97
|
+
function decodeWith(context, engine, data, success, failure) {
|
|
98
|
+
const bytes = new Uint8Array(data);
|
|
99
|
+
const decoding = isOggOpus(bytes) ? decodeOpus(bytes, context) : engine(data);
|
|
100
|
+
return decoding.then((buffer) => {
|
|
101
|
+
success?.(buffer);
|
|
102
|
+
return buffer;
|
|
103
|
+
}, (error) => {
|
|
104
|
+
failure?.(error);
|
|
105
|
+
throw error;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/** Watches a context's render capacity and counts the updates in which it could not keep up. */
|
|
109
|
+
export class UnderrunCounter {
|
|
110
|
+
underruns = 0;
|
|
111
|
+
constructor(capacity) {
|
|
112
|
+
capacity.addEventListener('update', (event) => {
|
|
113
|
+
if ((event.underrunRatio ?? 0) <= 0)
|
|
114
|
+
return;
|
|
115
|
+
this.underruns += 1;
|
|
116
|
+
if (this.underruns === 1) {
|
|
117
|
+
console.warn('[driftengine] audio underran: the output could not be fed in time. Counted from here on.');
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
capacity.start({ updateInterval: 1 });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** The two context classes a page has, over the Rust engine, with the host's defaults. */
|
|
124
|
+
export function hostAudioContexts(defaults = {}) {
|
|
125
|
+
/* The Rust engine's scheduled ramps, corrected to Chrome's (`audioParams.ts`). */
|
|
126
|
+
correctAutomation();
|
|
127
|
+
const options = {
|
|
128
|
+
...(defaults.sampleRate === undefined ? {} : { sampleRate: defaults.sampleRate }),
|
|
129
|
+
...(defaults.latencyHint === undefined ? {} : { latencyHint: defaults.latencyHint }),
|
|
130
|
+
...(defaults.sink === 'none' ? { sinkId: { type: 'none' } } : {}),
|
|
131
|
+
};
|
|
132
|
+
class HostAudioContext extends webAudio.AudioContext {
|
|
133
|
+
underruns;
|
|
134
|
+
constructor(asked = {}) {
|
|
135
|
+
super({ ...options, ...asked });
|
|
136
|
+
/*
|
|
137
|
+
* Reported once the device has said what it gave, which it does a few milliseconds after the
|
|
138
|
+
* context starts: under JACK the server sets the buffer, so `latencyHint` is a request
|
|
139
|
+
* (measured here: 21 ms for either category, PipeWire's quantum). Asked for rather than
|
|
140
|
+
* listened for, because starting the render-capacity watch below starts the context before
|
|
141
|
+
* a `statechange` listener could hear it.
|
|
142
|
+
*/
|
|
143
|
+
if (defaults.sink !== 'none') {
|
|
144
|
+
let tries = 0;
|
|
145
|
+
const report = () => {
|
|
146
|
+
tries += 1;
|
|
147
|
+
if (this.outputLatency <= 0 && tries < 50) {
|
|
148
|
+
setTimeout(report, 20).unref();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const frames = Math.round(this.outputLatency * this.sampleRate);
|
|
152
|
+
console.log(`[driftengine] audio: ${this.sampleRate} Hz, ${frames} frames of output latency (${(this.outputLatency * 1000).toFixed(1)} ms)`);
|
|
153
|
+
};
|
|
154
|
+
setTimeout(report, 20).unref();
|
|
155
|
+
}
|
|
156
|
+
/* Nothing renders against a clock with no device, so there is nothing to underrun. */
|
|
157
|
+
this.underruns =
|
|
158
|
+
defaults.sink === 'none'
|
|
159
|
+
? null
|
|
160
|
+
: new UnderrunCounter(this
|
|
161
|
+
.renderCapacity);
|
|
162
|
+
}
|
|
163
|
+
decodeAudioData(data, success, failure) {
|
|
164
|
+
return decodeWith(this, (bytes) => super.decodeAudioData(bytes), data, success, failure);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
class HostOfflineAudioContext extends webAudio.OfflineAudioContext {
|
|
168
|
+
decodeAudioData(data, success, failure) {
|
|
169
|
+
return decodeWith(this, (bytes) => super.decodeAudioData(bytes), data, success, failure);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
AudioContext: HostAudioContext,
|
|
174
|
+
OfflineAudioContext: HostOfflineAudioContext,
|
|
175
|
+
AudioBuffer: webAudio.AudioBuffer,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/** Give the page its audio globals, and hand back what takes them away again. */
|
|
179
|
+
export function installAudio(defaults = {}, scope = globalThis) {
|
|
180
|
+
const contexts = hostAudioContexts(defaults);
|
|
181
|
+
const held = ['AudioContext', 'OfflineAudioContext', 'AudioBuffer'].map((name) => [name, name in scope, scope[name]]);
|
|
182
|
+
scope['AudioContext'] = contexts.AudioContext;
|
|
183
|
+
scope['OfflineAudioContext'] = contexts.OfflineAudioContext;
|
|
184
|
+
scope['AudioBuffer'] = contexts.AudioBuffer;
|
|
185
|
+
return () => {
|
|
186
|
+
for (const [name, had, value] of held) {
|
|
187
|
+
if (had)
|
|
188
|
+
scope[name] = value;
|
|
189
|
+
else
|
|
190
|
+
delete scope[name];
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A defect in `node-web-audio-api` 2.2.0's automation, corrected for the engine's ramps until it is
|
|
3
|
+
* fixed upstream.
|
|
4
|
+
*
|
|
5
|
+
* **The defect, measured against Chrome on 2026-09-19.** A `setTargetAtTime` whose start is past
|
|
6
|
+
* the first render quantum, following an event that has *settled* — the value simply set, a
|
|
7
|
+
* `setValueAtTime`, the end of a linear or exponential ramp — renders garbage from the second
|
|
8
|
+
* quantum until it starts: 31.1 for a gain holding at 1, 10.5 for one a ramp left at 0.5, a
|
|
9
|
+
* low-pass at −839,731 Hz. From its start on it is Chrome's to four places, and following another
|
|
10
|
+
* `setTargetAtTime` it is right throughout. Every other shape the engine schedules was compared
|
|
11
|
+
* and agrees. The engine's every level and cutoff move is this shape (`mix/inserts.ts`, `ramp`), so
|
|
12
|
+
* the reference mix came out 28 dB hot in its second second.
|
|
13
|
+
*
|
|
14
|
+
* **The correction is what the specification says the value already is.** Before a target starts,
|
|
15
|
+
* a parameter holds the value its last settled event left; so a `setValueAtTime` of exactly that
|
|
16
|
+
* value is placed at the target's start, which changes nothing a browser computes and gives this
|
|
17
|
+
* engine the event it renders correctly. To know that value, each parameter's scheduled events are
|
|
18
|
+
* written down as they are made. Where that record cannot say — a curve spanning the start, a
|
|
19
|
+
* `cancelAndHoldAtTime`, a value set while events are pending — nothing is added, rather than a
|
|
20
|
+
* guess.
|
|
21
|
+
*
|
|
22
|
+
* Remove this with the dependency's fix: `audioParams.test.ts` is Chrome's numbers and will say.
|
|
23
|
+
*/
|
|
24
|
+
/** Correct the Rust engine's parameters, once, for every context made after or before. */
|
|
25
|
+
export declare function correctAutomation(): void;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A defect in `node-web-audio-api` 2.2.0's automation, corrected for the engine's ramps until it is
|
|
3
|
+
* fixed upstream.
|
|
4
|
+
*
|
|
5
|
+
* **The defect, measured against Chrome on 2026-09-19.** A `setTargetAtTime` whose start is past
|
|
6
|
+
* the first render quantum, following an event that has *settled* — the value simply set, a
|
|
7
|
+
* `setValueAtTime`, the end of a linear or exponential ramp — renders garbage from the second
|
|
8
|
+
* quantum until it starts: 31.1 for a gain holding at 1, 10.5 for one a ramp left at 0.5, a
|
|
9
|
+
* low-pass at −839,731 Hz. From its start on it is Chrome's to four places, and following another
|
|
10
|
+
* `setTargetAtTime` it is right throughout. Every other shape the engine schedules was compared
|
|
11
|
+
* and agrees. The engine's every level and cutoff move is this shape (`mix/inserts.ts`, `ramp`), so
|
|
12
|
+
* the reference mix came out 28 dB hot in its second second.
|
|
13
|
+
*
|
|
14
|
+
* **The correction is what the specification says the value already is.** Before a target starts,
|
|
15
|
+
* a parameter holds the value its last settled event left; so a `setValueAtTime` of exactly that
|
|
16
|
+
* value is placed at the target's start, which changes nothing a browser computes and gives this
|
|
17
|
+
* engine the event it renders correctly. To know that value, each parameter's scheduled events are
|
|
18
|
+
* written down as they are made. Where that record cannot say — a curve spanning the start, a
|
|
19
|
+
* `cancelAndHoldAtTime`, a value set while events are pending — nothing is added, rather than a
|
|
20
|
+
* guess.
|
|
21
|
+
*
|
|
22
|
+
* Remove this with the dependency's fix: `audioParams.test.ts` is Chrome's numbers and will say.
|
|
23
|
+
*/
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
/** Each parameter's events, time-ordered, or `null` once the record cannot be trusted. */
|
|
26
|
+
const records = new WeakMap();
|
|
27
|
+
function recordOf(param) {
|
|
28
|
+
let record = records.get(param);
|
|
29
|
+
if (record === undefined) {
|
|
30
|
+
record = [];
|
|
31
|
+
records.set(param, record);
|
|
32
|
+
}
|
|
33
|
+
return record;
|
|
34
|
+
}
|
|
35
|
+
function add(param, event) {
|
|
36
|
+
const record = recordOf(param);
|
|
37
|
+
if (record === null)
|
|
38
|
+
return;
|
|
39
|
+
let at = record.length;
|
|
40
|
+
while (at > 0 && record[at - 1].time > event.time)
|
|
41
|
+
at -= 1;
|
|
42
|
+
record.splice(at, 0, event);
|
|
43
|
+
}
|
|
44
|
+
let installed = false;
|
|
45
|
+
/** Correct the Rust engine's parameters, once, for every context made after or before. */
|
|
46
|
+
export function correctAutomation() {
|
|
47
|
+
if (installed)
|
|
48
|
+
return;
|
|
49
|
+
installed = true;
|
|
50
|
+
const { AudioParam: Param } = createRequire(import.meta.url)('node-web-audio-api');
|
|
51
|
+
const proto = Param.prototype;
|
|
52
|
+
const original = {
|
|
53
|
+
setValueAtTime: proto.setValueAtTime,
|
|
54
|
+
linearRampToValueAtTime: proto.linearRampToValueAtTime,
|
|
55
|
+
exponentialRampToValueAtTime: proto.exponentialRampToValueAtTime,
|
|
56
|
+
setTargetAtTime: proto.setTargetAtTime,
|
|
57
|
+
setValueCurveAtTime: proto.setValueCurveAtTime,
|
|
58
|
+
cancelScheduledValues: proto.cancelScheduledValues,
|
|
59
|
+
cancelAndHoldAtTime: proto.cancelAndHoldAtTime,
|
|
60
|
+
};
|
|
61
|
+
const value = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
62
|
+
proto.setValueAtTime = function (next, time) {
|
|
63
|
+
add(this, { time, settled: true, value: next });
|
|
64
|
+
return original.setValueAtTime.call(this, next, time);
|
|
65
|
+
};
|
|
66
|
+
proto.linearRampToValueAtTime = function (next, time) {
|
|
67
|
+
add(this, { time, settled: true, value: next });
|
|
68
|
+
return original.linearRampToValueAtTime.call(this, next, time);
|
|
69
|
+
};
|
|
70
|
+
proto.exponentialRampToValueAtTime = function (next, time) {
|
|
71
|
+
add(this, { time, settled: true, value: next });
|
|
72
|
+
return original.exponentialRampToValueAtTime.call(this, next, time);
|
|
73
|
+
};
|
|
74
|
+
proto.setValueCurveAtTime = function (values, start, duration) {
|
|
75
|
+
add(this, { time: start + duration, settled: true, value: values[values.length - 1] ?? 0 });
|
|
76
|
+
/* A curve spans its duration, and a target starting inside it has no settled value to hold. */
|
|
77
|
+
add(this, { time: start, settled: false, value: Number.NaN });
|
|
78
|
+
return original.setValueCurveAtTime.call(this, values, start, duration);
|
|
79
|
+
};
|
|
80
|
+
proto.cancelScheduledValues = function (time) {
|
|
81
|
+
const record = recordOf(this);
|
|
82
|
+
if (record !== null) {
|
|
83
|
+
while (record.length > 0 && record[record.length - 1].time >= time)
|
|
84
|
+
record.pop();
|
|
85
|
+
}
|
|
86
|
+
return original.cancelScheduledValues.call(this, time);
|
|
87
|
+
};
|
|
88
|
+
proto.cancelAndHoldAtTime = function (time) {
|
|
89
|
+
records.set(this, null);
|
|
90
|
+
return original.cancelAndHoldAtTime.call(this, time);
|
|
91
|
+
};
|
|
92
|
+
proto.setTargetAtTime = function (target, start, constant) {
|
|
93
|
+
const record = recordOf(this);
|
|
94
|
+
if (record !== null) {
|
|
95
|
+
let last;
|
|
96
|
+
for (const event of record)
|
|
97
|
+
if (event.time <= start)
|
|
98
|
+
last = event;
|
|
99
|
+
/* Nothing before it: what it holds is the value the parameter was simply given. */
|
|
100
|
+
const held = last === undefined
|
|
101
|
+
? this.value
|
|
102
|
+
: last.settled
|
|
103
|
+
? last.value
|
|
104
|
+
: null;
|
|
105
|
+
if (held !== null && Number.isFinite(held)) {
|
|
106
|
+
add(this, { time: start, settled: true, value: held });
|
|
107
|
+
original.setValueAtTime.call(this, held, start);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
add(this, { time: start, settled: false, value: target });
|
|
111
|
+
return original.setTargetAtTime.call(this, target, start, constant);
|
|
112
|
+
};
|
|
113
|
+
if (value?.set !== undefined && value.get !== undefined) {
|
|
114
|
+
const { get, set } = value;
|
|
115
|
+
Object.defineProperty(proto, 'value', {
|
|
116
|
+
configurable: true,
|
|
117
|
+
enumerable: value.enumerable ?? false,
|
|
118
|
+
get,
|
|
119
|
+
set(next) {
|
|
120
|
+
/* With events pending, a value set now is an event at a time this cannot see. */
|
|
121
|
+
const record = records.get(this);
|
|
122
|
+
if (record !== undefined && record !== null && record.length > 0)
|
|
123
|
+
records.set(this, null);
|
|
124
|
+
set.call(this, next);
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
package/dist/bridge.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell's bridge, `globalThis.__driftHost`, answered by this host.
|
|
3
|
+
*
|
|
4
|
+
* **A packaged game reaches its platform through `createHost()`**, from `@driftengine/package`, which
|
|
5
|
+
* takes the shell's implementations whenever it finds this bridge — and the engine's splash stands
|
|
6
|
+
* aside for a shell. The desktop shell's preload puts one there over Electron's IPC; this host puts
|
|
7
|
+
* its own there over SDL and the filesystem, so a game packaged for one runs on the other with no
|
|
8
|
+
* change. Each member makes the decision the desktop shell's main process makes (`main/ipc.ts`), for
|
|
9
|
+
* the same reasons, written beside it.
|
|
10
|
+
*
|
|
11
|
+
* - **The store** is one file, read whole before the game boots and replaced whole a quarter of a
|
|
12
|
+
* second after the last write — `storeFile.ts`, which the desktop shell uses too — and written at
|
|
13
|
+
* once, synchronously, on the way out, because a debounce that loses its last write loses exactly
|
|
14
|
+
* the setting somebody changed before quitting.
|
|
15
|
+
* - **Fullscreen** is read back after it is asked for, since a window manager may refuse, and a
|
|
16
|
+
* size is refused while the window covers a display.
|
|
17
|
+
* - **Closing** asks a game that registered to be asked, and waits for it at most three seconds; a
|
|
18
|
+
* game that did not is closed at once.
|
|
19
|
+
*
|
|
20
|
+
* What it gives up: SDL 2 reports no scale for a display, so a display the window is not on reads
|
|
21
|
+
* 1; the store has no cloud; and `platform` answers that no store's features are here.
|
|
22
|
+
*/
|
|
23
|
+
import type { DriftHostBridge } from '@driftengine/package/bridge';
|
|
24
|
+
/** A display, as SDL reports one. */
|
|
25
|
+
export interface BridgeDisplay {
|
|
26
|
+
readonly name: string | null;
|
|
27
|
+
readonly frequency: number;
|
|
28
|
+
readonly geometry: {
|
|
29
|
+
readonly x: number;
|
|
30
|
+
readonly y: number;
|
|
31
|
+
readonly width: number;
|
|
32
|
+
readonly height: number;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** What the bridge reads and drives of the SDL window. */
|
|
36
|
+
export interface BridgeWindow {
|
|
37
|
+
readonly width: number;
|
|
38
|
+
readonly height: number;
|
|
39
|
+
readonly pixelWidth: number;
|
|
40
|
+
readonly fullscreen: boolean;
|
|
41
|
+
readonly display: BridgeDisplay;
|
|
42
|
+
setFullscreen(on: boolean): void;
|
|
43
|
+
setSize(width: number, height: number): void;
|
|
44
|
+
on(type: 'focus' | 'blur' | 'beforeClose', listener: (event: {
|
|
45
|
+
prevent?: () => void;
|
|
46
|
+
}) => void): unknown;
|
|
47
|
+
off(type: 'focus' | 'blur' | 'beforeClose', listener: (event: {
|
|
48
|
+
prevent?: () => void;
|
|
49
|
+
}) => void): unknown;
|
|
50
|
+
}
|
|
51
|
+
export interface NativeFiles {
|
|
52
|
+
openFile(accept: readonly string[]): Promise<{
|
|
53
|
+
name: string;
|
|
54
|
+
bytes: Uint8Array;
|
|
55
|
+
} | null>;
|
|
56
|
+
saveFile(name: string, bytes: Uint8Array): Promise<boolean>;
|
|
57
|
+
}
|
|
58
|
+
export interface NativeBridgeOptions {
|
|
59
|
+
readonly window: BridgeWindow;
|
|
60
|
+
readonly displays: () => readonly BridgeDisplay[];
|
|
61
|
+
readonly storePath: string;
|
|
62
|
+
/** End the session: the host stops drawing and closes the window. */
|
|
63
|
+
readonly quit: () => void;
|
|
64
|
+
readonly files?: NativeFiles;
|
|
65
|
+
}
|
|
66
|
+
/** The bridge, and what writes its store now. */
|
|
67
|
+
export interface NativeBridge extends DriftHostBridge {
|
|
68
|
+
flushSync(): void;
|
|
69
|
+
}
|
|
70
|
+
export declare function nativeBridge(options: NativeBridgeOptions): NativeBridge;
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell's bridge, `globalThis.__driftHost`, answered by this host.
|
|
3
|
+
*
|
|
4
|
+
* **A packaged game reaches its platform through `createHost()`**, from `@driftengine/package`, which
|
|
5
|
+
* takes the shell's implementations whenever it finds this bridge — and the engine's splash stands
|
|
6
|
+
* aside for a shell. The desktop shell's preload puts one there over Electron's IPC; this host puts
|
|
7
|
+
* its own there over SDL and the filesystem, so a game packaged for one runs on the other with no
|
|
8
|
+
* change. Each member makes the decision the desktop shell's main process makes (`main/ipc.ts`), for
|
|
9
|
+
* the same reasons, written beside it.
|
|
10
|
+
*
|
|
11
|
+
* - **The store** is one file, read whole before the game boots and replaced whole a quarter of a
|
|
12
|
+
* second after the last write — `storeFile.ts`, which the desktop shell uses too — and written at
|
|
13
|
+
* once, synchronously, on the way out, because a debounce that loses its last write loses exactly
|
|
14
|
+
* the setting somebody changed before quitting.
|
|
15
|
+
* - **Fullscreen** is read back after it is asked for, since a window manager may refuse, and a
|
|
16
|
+
* size is refused while the window covers a display.
|
|
17
|
+
* - **Closing** asks a game that registered to be asked, and waits for it at most three seconds; a
|
|
18
|
+
* game that did not is closed at once.
|
|
19
|
+
*
|
|
20
|
+
* What it gives up: SDL 2 reports no scale for a display, so a display the window is not on reads
|
|
21
|
+
* 1; the store has no cloud; and `platform` answers that no store's features are here.
|
|
22
|
+
*/
|
|
23
|
+
import { readStoreFile, writeStoreFile, writeStoreFileSync } from '@driftengine/package/store';
|
|
24
|
+
/** The desktop shell's figures (`main/ipc.ts`), for the same reasons. */
|
|
25
|
+
const WRITE_DEBOUNCE_MS = 250;
|
|
26
|
+
const QUIT_GRACE_MS = 3000;
|
|
27
|
+
const idOf = (display) => `${display.name ?? ''}@${display.geometry.x},${display.geometry.y}`;
|
|
28
|
+
/**
|
|
29
|
+
* No file dialog on this desktop: a cancelled pick and a refused save, which a game already handles,
|
|
30
|
+
* and one line saying why, so a player whose picker never opens is told what to install.
|
|
31
|
+
*/
|
|
32
|
+
function noFiles() {
|
|
33
|
+
let said = false;
|
|
34
|
+
const say = () => {
|
|
35
|
+
if (said)
|
|
36
|
+
return;
|
|
37
|
+
said = true;
|
|
38
|
+
console.warn('[driftengine] this desktop has no file dialog for the native host: install zenity');
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
openFile: () => {
|
|
42
|
+
say();
|
|
43
|
+
return Promise.resolve(null);
|
|
44
|
+
},
|
|
45
|
+
saveFile: () => {
|
|
46
|
+
say();
|
|
47
|
+
return Promise.resolve(false);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function nativeBridge(options) {
|
|
52
|
+
const { window, storePath } = options;
|
|
53
|
+
const snapshot = readStoreFile(storePath);
|
|
54
|
+
const entries = new Map(Object.entries(snapshot));
|
|
55
|
+
let pending = null;
|
|
56
|
+
const flush = () => {
|
|
57
|
+
pending = null;
|
|
58
|
+
void writeStoreFile(storePath, entries).catch((cause) => console.error('[driftengine] could not write the store:', cause));
|
|
59
|
+
};
|
|
60
|
+
const schedule = () => {
|
|
61
|
+
if (pending === null)
|
|
62
|
+
pending = setTimeout(flush, WRITE_DEBOUNCE_MS);
|
|
63
|
+
};
|
|
64
|
+
const flushSync = () => {
|
|
65
|
+
if (pending === null)
|
|
66
|
+
return;
|
|
67
|
+
clearTimeout(pending);
|
|
68
|
+
pending = null;
|
|
69
|
+
try {
|
|
70
|
+
writeStoreFileSync(storePath, entries);
|
|
71
|
+
}
|
|
72
|
+
catch (cause) {
|
|
73
|
+
console.error('[driftengine] could not write the store on the way out:', cause);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
/* One decision for the two members that need it, as the desktop shell's `resizable` is. */
|
|
77
|
+
const resizable = () => !window.fullscreen;
|
|
78
|
+
const quitHandlers = new Set();
|
|
79
|
+
let grace = null;
|
|
80
|
+
const quit = () => {
|
|
81
|
+
if (grace !== null)
|
|
82
|
+
clearTimeout(grace);
|
|
83
|
+
grace = null;
|
|
84
|
+
flushSync();
|
|
85
|
+
options.quit();
|
|
86
|
+
};
|
|
87
|
+
window.on('beforeClose', (event) => {
|
|
88
|
+
if (quitHandlers.size === 0) {
|
|
89
|
+
quit();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
event.prevent?.();
|
|
93
|
+
for (const handler of quitHandlers)
|
|
94
|
+
handler();
|
|
95
|
+
/* A ceiling, not a wait: a game that answers with `requestQuit` closes at once. */
|
|
96
|
+
grace ??= setTimeout(quit, QUIT_GRACE_MS);
|
|
97
|
+
});
|
|
98
|
+
const files = options.files ?? noFiles();
|
|
99
|
+
return {
|
|
100
|
+
storeSnapshot: snapshot,
|
|
101
|
+
writeKey(key, value) {
|
|
102
|
+
entries.set(key, value);
|
|
103
|
+
schedule();
|
|
104
|
+
},
|
|
105
|
+
removeKey(key) {
|
|
106
|
+
entries.delete(key);
|
|
107
|
+
schedule();
|
|
108
|
+
},
|
|
109
|
+
flushSync,
|
|
110
|
+
setFullscreen(on) {
|
|
111
|
+
window.setFullscreen(on);
|
|
112
|
+
return Promise.resolve();
|
|
113
|
+
},
|
|
114
|
+
isFullscreen: () => window.fullscreen,
|
|
115
|
+
refreshHz: () => (window.display.frequency > 0 ? window.display.frequency : null),
|
|
116
|
+
mode: () => (window.fullscreen ? 'fullscreen' : 'windowed'),
|
|
117
|
+
setMode(mode) {
|
|
118
|
+
window.setFullscreen(mode === 'fullscreen');
|
|
119
|
+
/* Read back rather than assumed: a window manager may refuse. */
|
|
120
|
+
return Promise.resolve(window.fullscreen === (mode === 'fullscreen'));
|
|
121
|
+
},
|
|
122
|
+
size: () => ({ width: window.width, height: window.height }),
|
|
123
|
+
canSetSize: resizable,
|
|
124
|
+
setSize(width, height) {
|
|
125
|
+
if (!resizable())
|
|
126
|
+
return Promise.resolve(false);
|
|
127
|
+
window.setSize(Math.round(width), Math.round(height));
|
|
128
|
+
return Promise.resolve(true);
|
|
129
|
+
},
|
|
130
|
+
displays() {
|
|
131
|
+
const on = idOf(window.display);
|
|
132
|
+
return options.displays().map((display, at) => ({
|
|
133
|
+
id: idOf(display),
|
|
134
|
+
/* A size where there is no name, which is what somebody choosing a monitor recognises. */
|
|
135
|
+
label: display.name !== null && display.name.length > 0
|
|
136
|
+
? display.name
|
|
137
|
+
: `${display.geometry.width}x${display.geometry.height}`,
|
|
138
|
+
width: display.geometry.width,
|
|
139
|
+
height: display.geometry.height,
|
|
140
|
+
scale: idOf(display) === on ? window.pixelWidth / window.width : 1,
|
|
141
|
+
refreshHz: display.frequency > 0 ? display.frequency : null,
|
|
142
|
+
/* SDL lists the primary display first. */
|
|
143
|
+
primary: at === 0,
|
|
144
|
+
}));
|
|
145
|
+
},
|
|
146
|
+
onFocusChange(handler) {
|
|
147
|
+
const focus = () => handler(true);
|
|
148
|
+
const blur = () => handler(false);
|
|
149
|
+
window.on('focus', focus);
|
|
150
|
+
window.on('blur', blur);
|
|
151
|
+
return () => {
|
|
152
|
+
window.off('focus', focus);
|
|
153
|
+
window.off('blur', blur);
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
onQuitRequest(handler) {
|
|
157
|
+
quitHandlers.add(handler);
|
|
158
|
+
return () => quitHandlers.delete(handler);
|
|
159
|
+
},
|
|
160
|
+
canQuit: true,
|
|
161
|
+
requestQuit: quit,
|
|
162
|
+
openFile: (accept) => files.openFile(accept),
|
|
163
|
+
saveFile: (name, bytes) => files.saveFile(name, bytes),
|
|
164
|
+
platform: {
|
|
165
|
+
available: false,
|
|
166
|
+
unlockAchievement: () => undefined,
|
|
167
|
+
setRichPresence: () => undefined,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|