dewdrop-sdk 0.4.1 → 0.5.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/README.md CHANGED
@@ -20,7 +20,7 @@ export default { plugins: [dew()] };
20
20
 
21
21
  `npx dewctl init` sets up a new game this way, with the Dewdrop CLI beside it.
22
22
  To update a project, run `npm install -D dewdrop-sdk@latest`; saves need 0.4.0
23
- or later.
23
+ or later, voice 0.5.0.
24
24
 
25
25
  ## Saves locally
26
26
 
@@ -58,6 +58,51 @@ included. Call `saves.save()` then to keep what the player has done. `npm
58
58
  run dev` ends no session ahead of time, so `session.ending` stays `null`
59
59
  there.
60
60
 
61
+ ## Voice
62
+
63
+ ```js
64
+ import { backend, voice } from 'dewdrop-sdk';
65
+
66
+ // This player's microphone, a chunk at a time, sent to the backend each frame.
67
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
68
+ const capture = voice.capture(stream); // capture.stop() to go quiet
69
+ function frame() {
70
+ for (const chunk of capture.read()) backend.send(chunk); // Int16Array, 800 samples at 48 kHz
71
+ requestAnimationFrame(frame);
72
+ }
73
+ requestAnimationFrame(frame);
74
+
75
+ // Another player's voice, as the backend sends it on: from their figure,
76
+ // loud up close, fading with distance.
77
+ const source = voice.source(listener.context);
78
+ const sound = new PositionalAudio(listener); // on their avatar
79
+ sound.setNodeSource(source.node); // or source.node.connect(anywhere)
80
+ backend.onmessage = ({ data }) => source.write(new Int16Array(data, 4)); // behind the backend's own header
81
+ ```
82
+
83
+ Dewdrop carries no voice between players itself: your `backend.js`
84
+ decides who hears whom — those within earshot, a team, everyone — and
85
+ sends each chunk on through its ordinary messages, so a game can mix,
86
+ mute, record or transcribe as it sees fit. `voice.capture(stream)` takes
87
+ the live microphone stream from `getUserMedia` and hands back the
88
+ capture: `read()` returns the chunks that arrived since the last read,
89
+ each an `Int16Array` of 800 samples, mono, at `voice.SAMPLE_RATE`
90
+ (48 kHz), and `stop()` ends it; one capture at a time, a second throws
91
+ an `InvalidStateError`. A read is at most eight chunks of 1,600 bytes,
92
+ which a client's frame sends to its backend whole within its 16 KiB
93
+ with room beside, so a game may send every chunk it reads; a backend's
94
+ frame sends some 160 copies on within its 256 KiB, so a crowd all
95
+ speaking at once is more than one frame carries and a backend sheds the
96
+ rest — voice is late or nothing — or compresses first. `voice.source(context)`
97
+ is plain Web Audio: `node` is a node of `context` for `connect` or a
98
+ three.js `PositionalAudio`'s `setNodeSource`, `write(samples)` plays an
99
+ `Int16Array` or `Float32Array` right after those written before it —
100
+ the first two chunks ahead of the clock, so a chunk a little early or
101
+ late plays without a gap — dropping what runs eight chunks ahead, and
102
+ `close()` leaves the graph. `npm run dev` reads your browser's
103
+ microphone through an audio worklet and hands your game the same chunks,
104
+ so voice works between tabs locally as it does on Dewdrop.
105
+
61
106
  ## Docs
62
107
 
63
108
  How to build your game, from the look to model calls, multiplayer, saves and
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dewdrop-sdk",
3
- "version": "0.4.1",
4
- "description": "Build a three.js game for Dewdrop: the Vite plugin, model calls, multiplayer and saves.",
3
+ "version": "0.5.0",
4
+ "description": "Build a three.js game for Dewdrop: the Vite plugin, model calls, multiplayer, saves and voice.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "exports": {
package/src/index.js CHANGED
@@ -5,11 +5,13 @@
5
5
  // runs, `clients`, in that backend, over its port back, and `saves` into
6
6
  // the browser's own storage, which the plugin's script empties as each
7
7
  // page loads; `session` says when a session ends ahead of time, which here
8
- // none does.
8
+ // none does; and `voice` reads the microphone through the browser's own
9
+ // audio worklet, the chunks Dewdrop's server would decode.
9
10
  export { llm } from './llm.js';
10
11
  export { backend } from './backend.js';
11
12
  export { clients } from './clients.js';
12
13
  export { session } from './session.js';
14
+ export { voice } from './voice.js';
13
15
  import { attachBackend, joining } from './backend.js';
14
16
  import { load, save } from './saves.js';
15
17
 
package/src/voice.js ADDED
@@ -0,0 +1,192 @@
1
+ // `voice` on a developer's machine: the runtime's surface — the player's
2
+ // microphone a chunk at a time, and a source that plays such chunks
3
+ // through the game's own audio graph — where Dewdrop carries no voice
4
+ // between players itself: a game sends what it captures to its backend
5
+ // through its messages, and the backend sends it on to the players it
6
+ // chooses. On Dewdrop the server decodes the microphone the browser
7
+ // streams onto the game's voice stream; here the browser's own audio
8
+ // worklet reads the microphone and hands the game the same chunks, so a
9
+ // game captures in `npm run dev` as it captures on Dewdrop. `source` is
10
+ // plain Web Audio, the same function as Dewdrop's, and plays here as
11
+ // there.
12
+
13
+ // Hand-copied from the runtime, which tests/sync.test.mjs pins: the rate
14
+ // of the samples a capture reads and a source plays, the samples in one
15
+ // of a capture's chunks, 1/60 s, and the chunks that wait for a game that
16
+ // reads late, the newest kept.
17
+ export const SAMPLE_RATE = 48000;
18
+ export const VOICE_CHUNK_SAMPLES = 800;
19
+ export const VOICE_CHUNKS_HELD = 8;
20
+ // How far ahead of the context's clock a source begins what is written
21
+ // to it — the first chunk, and the first after it ran dry — in seconds:
22
+ // two chunks, which a later chunk may come late by and still follow the
23
+ // last without a gap; and how far ahead writes may run before the newest
24
+ // is dropped instead, so a voice falls no further behind.
25
+ const SOURCE_LEAD = 2 * VOICE_CHUNK_SAMPLES / SAMPLE_RATE;
26
+ const MAX_SOURCE_LEAD = 8 * VOICE_CHUNK_SAMPLES / SAMPLE_RATE;
27
+
28
+ // The processor that reads the microphone in the browser's audio thread:
29
+ // it takes the microphone's frames at the context's rate, steps through
30
+ // them at SAMPLE_RATE, and posts each VOICE_CHUNK_SAMPLES of them as 16-bit
31
+ // samples, the buffer handed over rather than copied. Its output is
32
+ // silence, so it can hang off the destination, which is what has it run.
33
+ export const CAPTURE_PROCESSOR = 'dew-voice-capture';
34
+ export const CAPTURE_WORKLET = `
35
+ class DewVoiceCapture extends AudioWorkletProcessor {
36
+ constructor() {
37
+ super();
38
+ this.chunk = new Int16Array(${VOICE_CHUNK_SAMPLES});
39
+ this.at = 0;
40
+ // The microphone's frames per sample, and where in the block the next
41
+ // sample falls.
42
+ this.step = sampleRate / ${SAMPLE_RATE};
43
+ this.phase = 0;
44
+ }
45
+ process(inputs) {
46
+ const frames = inputs[0][0];
47
+ if (frames === undefined) return true;
48
+ while (this.phase < frames.length) {
49
+ const i = Math.floor(this.phase);
50
+ const next = i + 1 < frames.length ? frames[i + 1] : frames[i];
51
+ const sample = frames[i] + (next - frames[i]) * (this.phase - i);
52
+ const clamped = sample < -1 ? -1 : sample > 1 ? 1 : sample;
53
+ this.chunk[this.at++] = Math.round(clamped * 32767);
54
+ if (this.at === this.chunk.length) {
55
+ this.port.postMessage(this.chunk, [this.chunk.buffer]);
56
+ this.chunk = new Int16Array(${VOICE_CHUNK_SAMPLES});
57
+ this.at = 0;
58
+ }
59
+ this.phase += this.step;
60
+ }
61
+ this.phase -= frames.length;
62
+ return true;
63
+ }
64
+ }
65
+ registerProcessor('${CAPTURE_PROCESSOR}', DewVoiceCapture);
66
+ `;
67
+
68
+ // The capture under way, if any.
69
+ let capturing = null;
70
+
71
+ // A stream with a live audio track, as `getUserMedia` resolves one.
72
+ function liveMicrophone(stream) {
73
+ return stream !== null && typeof stream === 'object' && typeof stream.getAudioTracks === 'function'
74
+ && stream.getAudioTracks().some((track) => track.readyState === 'live');
75
+ }
76
+
77
+ // The player's microphone, a chunk at a time: given the stream
78
+ // `getUserMedia` resolved, while its track is live, hands back the
79
+ // capture, whose `read()` returns the chunks that arrived since the last
80
+ // read — each an Int16Array of VOICE_CHUNK_SAMPLES samples, mono, at
81
+ // SAMPLE_RATE, in order, the newest VOICE_CHUNKS_HELD kept when the game
82
+ // reads late — and whose `stop()` ends it, closing the context at once.
83
+ // One at a time. The browser's own graph reads the stream: a context at
84
+ // the sample rate where the browser allows one, the worklet between the
85
+ // stream and the destination.
86
+ function capture(stream) {
87
+ if (!liveMicrophone(stream)) {
88
+ throw new TypeError('voice.capture: stream is not a live microphone stream from navigator.mediaDevices.getUserMedia');
89
+ }
90
+ if (capturing !== null) throw new DOMException('voice.capture: the microphone is captured already; stop that first', 'InvalidStateError');
91
+ if (typeof AudioContext === 'undefined' || typeof AudioWorkletNode === 'undefined') {
92
+ throw new DOMException('voice.capture is unavailable here: the browser has no AudioWorklet', 'NotSupportedError');
93
+ }
94
+ let context;
95
+ try {
96
+ context = new AudioContext({ sampleRate: SAMPLE_RATE });
97
+ } catch {
98
+ context = new AudioContext();
99
+ }
100
+ let chunks = [];
101
+ let live = true;
102
+ const worklet = URL.createObjectURL(new Blob([CAPTURE_WORKLET], { type: 'text/javascript' }));
103
+ const ready = context.audioWorklet.addModule(worklet).then(() => {
104
+ URL.revokeObjectURL(worklet);
105
+ if (!live) return undefined;
106
+ const node = new AudioWorkletNode(context, CAPTURE_PROCESSOR, {
107
+ numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1], channelCount: 1, channelCountMode: 'explicit',
108
+ });
109
+ node.port.onmessage = ({ data }) => {
110
+ if (!live) return;
111
+ chunks.push(data);
112
+ if (chunks.length > VOICE_CHUNKS_HELD) chunks.splice(0, chunks.length - VOICE_CHUNKS_HELD);
113
+ };
114
+ context.createMediaStreamSource(stream).connect(node).connect(context.destination);
115
+ // A context the browser keeps suspended until the player's next
116
+ // gesture starts then; nothing here waits on it.
117
+ context.resume().catch(() => {});
118
+ });
119
+ // A worklet the browser refuses leaves the capture silent; the reason
120
+ // goes to the console, where the developer is.
121
+ ready.catch((error) => console.error('voice.capture: the microphone could not be read: ' + error.message));
122
+ const mine = Object.freeze({
123
+ read() {
124
+ if (capturing !== mine || chunks.length === 0) return [];
125
+ const read = chunks;
126
+ chunks = [];
127
+ return read;
128
+ },
129
+ stop() {
130
+ if (capturing !== mine) return;
131
+ capturing = null;
132
+ live = false;
133
+ chunks = [];
134
+ // Closed at once, whether or not the worklet has loaded or the
135
+ // context ever ran: a setup still to come finds `live` false and
136
+ // makes nothing.
137
+ context.close().catch(() => {});
138
+ },
139
+ });
140
+ capturing = mine;
141
+ return mine;
142
+ }
143
+
144
+ function isObjectLike(value) { return value !== null && (typeof value === 'object' || typeof value === 'function'); }
145
+
146
+ // A source for `context`'s graph that plays the chunks written to it as
147
+ // they come — Int16Array or Float32Array samples at SAMPLE_RATE, mono,
148
+ // as `capture` reads them or as another player's reached this game —
149
+ // each right after the last: `node` is the source, for `connect`
150
+ // or a three.js PositionalAudio's `setNodeSource`; `write` plays a chunk
151
+ // after those before it; `close` leaves the graph. Plain Web Audio — a
152
+ // buffer source a write, begun SOURCE_LEAD ahead of the context's clock
153
+ // and following the last, and nothing of Dewdrop's — so a game may play a
154
+ // voice its own way instead. The runtime's web/voice.js carries this same
155
+ // function, which tests/sync.test.mjs pins line for line.
156
+ function source(context) {
157
+ if (!isObjectLike(context) || typeof context.createBufferSource !== 'function') {
158
+ throw new TypeError('voice.source: context is not an AudioContext');
159
+ }
160
+ const node = context.createGain();
161
+ // When the next chunk written plays, on the context's clock.
162
+ let at = 0;
163
+ return Object.freeze({
164
+ node,
165
+ write(samples) {
166
+ const tag = Object.prototype.toString.call(samples);
167
+ if (tag !== '[object Int16Array]' && tag !== '[object Float32Array]') {
168
+ throw new TypeError('voice.source: write takes an Int16Array or a Float32Array of samples');
169
+ }
170
+ if (samples.length === 0) return;
171
+ const now = context.currentTime;
172
+ // The first write, or one after the chunks before it played out:
173
+ // SOURCE_LEAD ahead of the clock. While they still play, right
174
+ // after them, however early or late this one came, which is what
175
+ // rides out the jitter; too far ahead of the clock: dropped.
176
+ if (at <= now) at = now + SOURCE_LEAD;
177
+ else if (at > now + MAX_SOURCE_LEAD) return;
178
+ const buffer = context.createBuffer(1, samples.length, SAMPLE_RATE);
179
+ const channel = buffer.getChannelData(0);
180
+ if (tag === '[object Float32Array]') channel.set(samples);
181
+ else for (let i = 0; i < samples.length; i++) channel[i] = samples[i] / 32768;
182
+ const player = context.createBufferSource();
183
+ player.buffer = buffer;
184
+ player.connect(node);
185
+ player.start(at);
186
+ at += buffer.duration;
187
+ },
188
+ close() { node.disconnect(); },
189
+ });
190
+ }
191
+
192
+ export const voice = Object.freeze({ capture, source, SAMPLE_RATE });