onelobby 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/dist/chat.d.ts +60 -0
- package/dist/chat.js +163 -0
- package/dist/chat.js.map +1 -0
- package/dist/chunking.d.ts +17 -0
- package/dist/chunking.js +59 -0
- package/dist/chunking.js.map +1 -0
- package/dist/clock.d.ts +8 -0
- package/dist/clock.js +39 -0
- package/dist/clock.js.map +1 -0
- package/dist/codec.d.ts +141 -0
- package/dist/codec.js +308 -0
- package/dist/codec.js.map +1 -0
- package/dist/crockford.d.ts +14 -0
- package/dist/crockford.js +55 -0
- package/dist/crockford.js.map +1 -0
- package/dist/deflate.d.ts +4 -0
- package/dist/deflate.js +32 -0
- package/dist/deflate.js.map +1 -0
- package/dist/deprecated.d.ts +18 -0
- package/dist/deprecated.js +11 -0
- package/dist/deprecated.js.map +1 -0
- package/dist/direct/hub-host.d.ts +82 -0
- package/dist/direct/hub-host.js +393 -0
- package/dist/direct/hub-host.js.map +1 -0
- package/dist/direct/hub.d.ts +178 -0
- package/dist/direct/hub.js +862 -0
- package/dist/direct/hub.js.map +1 -0
- package/dist/direct/index.d.ts +57 -0
- package/dist/direct/index.js +71 -0
- package/dist/direct/index.js.map +1 -0
- package/dist/direct/portmap.d.ts +84 -0
- package/dist/direct/portmap.js +465 -0
- package/dist/direct/portmap.js.map +1 -0
- package/dist/direct-address.d.ts +20 -0
- package/dist/direct-address.js +61 -0
- package/dist/direct-address.js.map +1 -0
- package/dist/emitter.d.ts +9 -0
- package/dist/emitter.js +36 -0
- package/dist/emitter.js.map +1 -0
- package/dist/friendly.d.ts +20 -0
- package/dist/friendly.js +120 -0
- package/dist/friendly.js.map +1 -0
- package/dist/global.d.ts +8 -0
- package/dist/global.js +9 -0
- package/dist/global.js.map +1 -0
- package/dist/gridpaint.d.ts +27 -0
- package/dist/gridpaint.js +106 -0
- package/dist/gridpaint.js.map +1 -0
- package/dist/hub.d.ts +25 -0
- package/dist/hub.js +2 -0
- package/dist/hub.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/internals.d.ts +28 -0
- package/dist/internals.js +24 -0
- package/dist/internals.js.map +1 -0
- package/dist/joincode.d.ts +21 -0
- package/dist/joincode.js +84 -0
- package/dist/joincode.js.map +1 -0
- package/dist/lobby.d.ts +203 -0
- package/dist/lobby.js +840 -0
- package/dist/lobby.js.map +1 -0
- package/dist/lockstep.d.ts +132 -0
- package/dist/lockstep.js +471 -0
- package/dist/lockstep.js.map +1 -0
- package/dist/migration.d.ts +17 -0
- package/dist/migration.js +34 -0
- package/dist/migration.js.map +1 -0
- package/dist/onelobby.bundle.mjs +140 -0
- package/dist/onelobby.bundle.mjs.map +7 -0
- package/dist/onelobby.d.ts +151 -0
- package/dist/onelobby.global.js +3897 -0
- package/dist/onelobby.global.js.map +7 -0
- package/dist/onelobby.global.min.js +140 -0
- package/dist/onelobby.global.min.js.map +7 -0
- package/dist/onelobby.js +337 -0
- package/dist/onelobby.js.map +1 -0
- package/dist/signaling.d.ts +94 -0
- package/dist/signaling.js +134 -0
- package/dist/signaling.js.map +1 -0
- package/dist/socket.d.ts +63 -0
- package/dist/socket.js +142 -0
- package/dist/socket.js.map +1 -0
- package/dist/testing.d.ts +135 -0
- package/dist/testing.js +438 -0
- package/dist/testing.js.map +1 -0
- package/dist/transport.d.ts +80 -0
- package/dist/transport.js +7 -0
- package/dist/transport.js.map +1 -0
- package/dist/ui/controller.d.ts +137 -0
- package/dist/ui/controller.js +337 -0
- package/dist/ui/controller.js.map +1 -0
- package/dist/ui/element.d.ts +22 -0
- package/dist/ui/element.js +284 -0
- package/dist/ui/element.js.map +1 -0
- package/dist/ui/index.d.ts +5 -0
- package/dist/ui/index.js +9 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/server-url.d.ts +9 -0
- package/dist/ui/server-url.js +39 -0
- package/dist/ui/server-url.js.map +1 -0
- package/dist/ui/styles.d.ts +2 -0
- package/dist/ui/styles.js +70 -0
- package/dist/ui/styles.js.map +1 -0
- package/dist/voice-browser.d.ts +19 -0
- package/dist/voice-browser.js +151 -0
- package/dist/voice-browser.js.map +1 -0
- package/dist/voice-core.d.ts +98 -0
- package/dist/voice-core.js +233 -0
- package/dist/voice-core.js.map +1 -0
- package/dist/voice-types.d.ts +35 -0
- package/dist/voice-types.js +3 -0
- package/dist/voice-types.js.map +1 -0
- package/dist/voice.d.ts +68 -0
- package/dist/voice.js +223 -0
- package/dist/voice.js.map +1 -0
- package/dist/webrtc.d.ts +29 -0
- package/dist/webrtc.js +169 -0
- package/dist/webrtc.js.map +1 -0
- package/package.json +93 -4
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/* Browser implementations of the voice seams: WebCodecs Opus, AudioWorklet mic capture, WebAudio playback. */
|
|
2
|
+
import { FRAME_SAMPLES, SAMPLE_RATE } from "./voice-core.js";
|
|
3
|
+
export function webCodecsOpusAvailable() {
|
|
4
|
+
return typeof AudioEncoder !== "undefined" && typeof AudioDecoder !== "undefined" && typeof AudioData !== "undefined";
|
|
5
|
+
}
|
|
6
|
+
/** Opus through WebCodecs (not Baseline: Chromium/Edge/Safari; check `webCodecsOpusAvailable()`). */
|
|
7
|
+
export const webCodecsOpus = {
|
|
8
|
+
createEncoder(opts, onFrame) {
|
|
9
|
+
let ts = 0;
|
|
10
|
+
const enc = new AudioEncoder({
|
|
11
|
+
output: (chunk) => {
|
|
12
|
+
const out = new Uint8Array(chunk.byteLength);
|
|
13
|
+
chunk.copyTo(out);
|
|
14
|
+
onFrame(out);
|
|
15
|
+
},
|
|
16
|
+
error: (e) => console.error("onelobby: AudioEncoder", e),
|
|
17
|
+
});
|
|
18
|
+
enc.configure({
|
|
19
|
+
codec: "opus",
|
|
20
|
+
sampleRate: SAMPLE_RATE,
|
|
21
|
+
numberOfChannels: 1,
|
|
22
|
+
bitrate: opts.bitrate,
|
|
23
|
+
opus: { frameDuration: 20000, application: "voip", useinbandfec: true, usedtx: false, packetlossperc: 10 },
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
encode(pcm) {
|
|
27
|
+
const data = new AudioData({
|
|
28
|
+
format: "f32",
|
|
29
|
+
sampleRate: SAMPLE_RATE,
|
|
30
|
+
numberOfFrames: pcm.length,
|
|
31
|
+
numberOfChannels: 1,
|
|
32
|
+
timestamp: ts,
|
|
33
|
+
data: pcm,
|
|
34
|
+
});
|
|
35
|
+
ts += 20000;
|
|
36
|
+
enc.encode(data);
|
|
37
|
+
data.close();
|
|
38
|
+
},
|
|
39
|
+
close() {
|
|
40
|
+
if (enc.state !== "closed")
|
|
41
|
+
enc.close();
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
createDecoder(onPcm) {
|
|
46
|
+
let ts = 0;
|
|
47
|
+
const dec = new AudioDecoder({
|
|
48
|
+
output: (audio) => {
|
|
49
|
+
const pcm = new Float32Array(audio.numberOfFrames);
|
|
50
|
+
audio.copyTo(pcm, { planeIndex: 0, format: "f32-planar" });
|
|
51
|
+
audio.close();
|
|
52
|
+
onPcm(pcm);
|
|
53
|
+
},
|
|
54
|
+
error: (e) => console.error("onelobby: AudioDecoder", e),
|
|
55
|
+
});
|
|
56
|
+
dec.configure({ codec: "opus", sampleRate: SAMPLE_RATE, numberOfChannels: 1 });
|
|
57
|
+
return {
|
|
58
|
+
decode(opus) {
|
|
59
|
+
if (opus === null) {
|
|
60
|
+
onPcm(new Float32Array(FRAME_SAMPLES)); // WebCodecs has no PLC hook: conceal with silence
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
dec.decode(new EncodedAudioChunk({ type: "key", timestamp: ts, data: opus }));
|
|
64
|
+
ts += 20000;
|
|
65
|
+
},
|
|
66
|
+
close() {
|
|
67
|
+
if (dec.state !== "closed")
|
|
68
|
+
dec.close();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
const WORKLET_SRC = `
|
|
74
|
+
class OneLobbyCapture extends AudioWorkletProcessor {
|
|
75
|
+
constructor() { super(); this.buf = new Float32Array(${FRAME_SAMPLES}); this.n = 0; }
|
|
76
|
+
process(inputs) {
|
|
77
|
+
const ch = inputs[0] && inputs[0][0];
|
|
78
|
+
if (ch) {
|
|
79
|
+
for (let i = 0; i < ch.length; i++) {
|
|
80
|
+
this.buf[this.n++] = ch[i];
|
|
81
|
+
if (this.n === ${FRAME_SAMPLES}) {
|
|
82
|
+
const out = this.buf; this.buf = new Float32Array(${FRAME_SAMPLES}); this.n = 0;
|
|
83
|
+
this.port.postMessage(out, [out.buffer]);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
registerProcessor('onelobby-capture', OneLobbyCapture);
|
|
91
|
+
`;
|
|
92
|
+
/** getUserMedia + AudioWorklet producing 960-sample frames at 48 kHz. Must be started from a user gesture. */
|
|
93
|
+
export class WorkletMic {
|
|
94
|
+
ctx = null;
|
|
95
|
+
stream = null;
|
|
96
|
+
async start(onFrame) {
|
|
97
|
+
this.stream = await navigator.mediaDevices.getUserMedia({
|
|
98
|
+
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
|
99
|
+
});
|
|
100
|
+
this.ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
|
101
|
+
const url = URL.createObjectURL(new Blob([WORKLET_SRC], { type: "text/javascript" }));
|
|
102
|
+
try {
|
|
103
|
+
await this.ctx.audioWorklet.addModule(url);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
URL.revokeObjectURL(url);
|
|
107
|
+
}
|
|
108
|
+
const src = this.ctx.createMediaStreamSource(this.stream);
|
|
109
|
+
const node = new AudioWorkletNode(this.ctx, "onelobby-capture", { numberOfInputs: 1, numberOfOutputs: 0 });
|
|
110
|
+
node.port.onmessage = (e) => onFrame(e.data);
|
|
111
|
+
src.connect(node);
|
|
112
|
+
if (this.ctx.state === "suspended")
|
|
113
|
+
await this.ctx.resume();
|
|
114
|
+
}
|
|
115
|
+
stop() {
|
|
116
|
+
for (const t of this.stream?.getTracks() ?? [])
|
|
117
|
+
t.stop();
|
|
118
|
+
void this.ctx?.close();
|
|
119
|
+
this.stream = null;
|
|
120
|
+
this.ctx = null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Plays decoded PCM per speaker, scheduled back to back on an AudioContext. */
|
|
124
|
+
export class WebAudioSink {
|
|
125
|
+
ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
|
126
|
+
next = new Map();
|
|
127
|
+
constructor() {
|
|
128
|
+
void this.ctx.resume();
|
|
129
|
+
}
|
|
130
|
+
play(peerId, pcm, gain) {
|
|
131
|
+
if (pcm.length === 0)
|
|
132
|
+
return;
|
|
133
|
+
const buf = this.ctx.createBuffer(1, pcm.length, SAMPLE_RATE);
|
|
134
|
+
buf.copyToChannel(pcm, 0);
|
|
135
|
+
const src = this.ctx.createBufferSource();
|
|
136
|
+
src.buffer = buf;
|
|
137
|
+
const g = this.ctx.createGain();
|
|
138
|
+
g.gain.value = gain;
|
|
139
|
+
src.connect(g).connect(this.ctx.destination);
|
|
140
|
+
const now = this.ctx.currentTime;
|
|
141
|
+
let at = this.next.get(peerId) ?? 0;
|
|
142
|
+
if (at < now + 0.01)
|
|
143
|
+
at = now + 0.02; // (re)started: small lead so scheduling never lands in the past
|
|
144
|
+
src.start(at);
|
|
145
|
+
this.next.set(peerId, at + pcm.length / SAMPLE_RATE);
|
|
146
|
+
}
|
|
147
|
+
close() {
|
|
148
|
+
void this.ctx.close();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
//# sourceMappingURL=voice-browser.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"voice-browser.js","sourceRoot":"","sources":["../src/voice-browser.ts"],"names":[],"mappings":"AAAA,8GAA8G;AAC9G,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAG7D,MAAM,UAAU,sBAAsB;IACpC,OAAO,OAAO,YAAY,KAAK,WAAW,IAAI,OAAO,YAAY,KAAK,WAAW,IAAI,OAAO,SAAS,KAAK,WAAW,CAAC;AACxH,CAAC;AAED,qGAAqG;AACrG,MAAM,CAAC,MAAM,aAAa,GAAe;IACvC,aAAa,CAAC,IAAI,EAAE,OAAO;QACzB,IAAI,EAAE,GAAG,CAAC,CAAC;QACX,MAAM,GAAG,GAAG,IAAI,YAAY,CAAC;YAC3B,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE;gBAChB,MAAM,GAAG,GAAG,IAAI,UAAU,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;gBAC7C,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;gBAClB,OAAO,CAAC,GAAG,CAAC,CAAC;YACf,CAAC;YACD,KAAK,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,wBAAwB,EAAE,CAAC,CAAC;SACzD,CAAC,CAAC;QACH,GAAG,CAAC,SAAS,CAAC;YACZ,KAAK,EAAE,MAAM;YACb,UAAU,EAAE,WAAW;YACvB,gBAAgB,EAAE,CAAC;YACnB,OAAO,EAAE,IAAI,CAAC,OAAO;YACrB,IAAI,EAAE,EAAE,aAAa,EAAE,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,cAAc,EAAE,EAAE,EAAE;SACrF,CAAC,CAAC;QACzB,OAAO;YACL,MAAM,CAAC,GAAG;gBACR,MAAM,IAAI,GAAG,IAAI,SAAS,CAAC;oBACzB,MAAM,EAAE,KAAK;oBACb,UAAU,EAAE,WAAW;oBACvB,cAAc,EAAE,GAAG,CAAC,MAAM;oBAC1B,gBAAgB,EAAE,CAAC;oBACnB,SAAS,EAAE,EAAE;oBACb,IAAI,EAAE,GAA2C;iBAClD,CAAC,CAAC;gBACH,EAAE,IAAI,KAAK,CAAC;gBACZ,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;gBACjB,IAAI,CAAC,KAAK,EAAE,CAAC;YACf,CAAC;YACD,KAAK;gBACH,IAAI,GAAG,CAAC,KAAK,KAAK,QAAQ;oBAAE,GAAG,CAAC,KAAK,EAAE,CAAC;YAC1C,CAAC;SACF,CAAC;IACJ,CAAC;IACD,aAAa,CAAC,KAAK;QACjB,IAAI,EAAE,GAAG,CAAC,CAAC;QACX,MAAM,GAAG,GAAG,IAAI,YAAY,CAAC;YAC3B,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE;gBAChB,MAAM,GAAG,GAAG,IAAI,YAAY,CAAC,KAAK,CAAC,cAAc,CAAC,CAAC;gBACnD,KAAK,CAAC,MAAM,CAAC,GAAG,EAAE,EAAE,UAAU,EAAE,CAAC,EAAE,MAAM,EAAE,YAAY,EAA4B,CAAC,CAAC;gBACrF,KAAK,CAAC,KAAK,EAAE,CAAC;gBACd,KAAK,CAAC,GAAG,CAAC,CAAC;YACb,CAAC;YACD,KAAK,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,wBAAwB,EAAE,CAAC,CAAC;SACzD,CAAC,CAAC;QACH,GAAG,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,WAAW,EAAE,gBAAgB,EAAE,CAAC,EAAE,CAAC,CAAC;QAC/E,OAAO;YACL,MAAM,CAAC,IAAI;gBACT,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;oBAClB,KAAK,CAAC,IAAI,YAAY,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,kDAAkD;oBAC1F,OAAO;gBACT,CAAC;gBACD,GAAG,CAAC,MAAM,CAAC,IAAI,iBAAiB,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;gBAC9E,EAAE,IAAI,KAAK,CAAC;YACd,CAAC;YACD,KAAK;gBACH,IAAI,GAAG,CAAC,KAAK,KAAK,QAAQ;oBAAE,GAAG,CAAC,KAAK,EAAE,CAAC;YAC1C,CAAC;SACF,CAAC;IACJ,CAAC;CACF,CAAC;AAEF,MAAM,WAAW,GAAG;;yDAEqC,aAAa;;;;;;yBAM7C,aAAa;8DACwB,aAAa;;;;;;;;;CAS1E,CAAC;AAEF,8GAA8G;AAC9G,MAAM,OAAO,UAAU;IACb,GAAG,GAAwB,IAAI,CAAC;IAChC,MAAM,GAAuB,IAAI,CAAC;IAE1C,KAAK,CAAC,KAAK,CAAC,OAAoC;QAC9C,IAAI,CAAC,MAAM,GAAG,MAAM,SAAS,CAAC,YAAY,CAAC,YAAY,CAAC;YACtD,KAAK,EAAE,EAAE,YAAY,EAAE,CAAC,EAAE,gBAAgB,EAAE,IAAI,EAAE,gBAAgB,EAAE,IAAI,EAAE,eAAe,EAAE,IAAI,EAAE;SAClG,CAAC,CAAC;QACH,IAAI,CAAC,GAAG,GAAG,IAAI,YAAY,CAAC,EAAE,UAAU,EAAE,WAAW,EAAE,CAAC,CAAC;QACzD,MAAM,GAAG,GAAG,GAAG,CAAC,eAAe,CAAC,IAAI,IAAI,CAAC,CAAC,WAAW,CAAC,EAAE,EAAE,IAAI,EAAE,iBAAiB,EAAE,CAAC,CAAC,CAAC;QACtF,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,GAAG,CAAC,YAAY,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QAC7C,CAAC;gBAAS,CAAC;YACT,GAAG,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC;QAC3B,CAAC;QACD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,uBAAuB,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAC1D,MAAM,IAAI,GAAG,IAAI,gBAAgB,CAAC,IAAI,CAAC,GAAG,EAAE,kBAAkB,EAAE,EAAE,cAAc,EAAE,CAAC,EAAE,eAAe,EAAE,CAAC,EAAE,CAAC,CAAC;QAC3G,IAAI,CAAC,IAAI,CAAC,SAAS,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,IAAoB,CAAC,CAAC;QAC7D,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QAClB,IAAI,IAAI,CAAC,GAAG,CAAC,KAAK,KAAK,WAAW;YAAE,MAAM,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC;IAC9D,CAAC;IAED,IAAI;QACF,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE;YAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QACzD,KAAK,IAAI,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC;QACvB,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;QACnB,IAAI,CAAC,GAAG,GAAG,IAAI,CAAC;IAClB,CAAC;CACF;AAED,gFAAgF;AAChF,MAAM,OAAO,YAAY;IACf,GAAG,GAAG,IAAI,YAAY,CAAC,EAAE,UAAU,EAAE,WAAW,EAAE,CAAC,CAAC;IACpD,IAAI,GAAG,IAAI,GAAG,EAAkB,CAAC;IAEzC;QACE,KAAK,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC;IACzB,CAAC;IAED,IAAI,CAAC,MAAc,EAAE,GAAiB,EAAE,IAAY;QAClD,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO;QAC7B,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;QAC9D,GAAG,CAAC,aAAa,CAAC,GAA2C,EAAE,CAAC,CAAC,CAAC;QAClE,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,kBAAkB,EAAE,CAAC;QAC1C,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC;QACjB,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,CAAC;QAChC,CAAC,CAAC,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;QACpB,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;QAC7C,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,WAAW,CAAC;QACjC,IAAI,EAAE,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QACpC,IAAI,EAAE,GAAG,GAAG,GAAG,IAAI;YAAE,EAAE,GAAG,GAAG,GAAG,IAAI,CAAC,CAAC,gEAAgE;QACtG,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QACd,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,EAAE,GAAG,GAAG,CAAC,MAAM,GAAG,WAAW,CAAC,CAAC;IACvD,CAAC;IAED,KAAK;QACH,KAAK,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC;IACxB,CAAC;CACF","sourcesContent":["/* Browser implementations of the voice seams: WebCodecs Opus, AudioWorklet mic capture, WebAudio playback. */\nimport { FRAME_SAMPLES, SAMPLE_RATE } from \"./voice-core.js\";\nimport type { AudioSink, FrameSource, VoiceCodec, VoiceDecoderHandle, VoiceEncoderHandle } from \"./voice-types.js\";\n\nexport function webCodecsOpusAvailable(): boolean {\n return typeof AudioEncoder !== \"undefined\" && typeof AudioDecoder !== \"undefined\" && typeof AudioData !== \"undefined\";\n}\n\n/** Opus through WebCodecs (not Baseline: Chromium/Edge/Safari; check `webCodecsOpusAvailable()`). */\nexport const webCodecsOpus: VoiceCodec = {\n createEncoder(opts, onFrame): VoiceEncoderHandle {\n let ts = 0;\n const enc = new AudioEncoder({\n output: (chunk) => {\n const out = new Uint8Array(chunk.byteLength);\n chunk.copyTo(out);\n onFrame(out);\n },\n error: (e) => console.error(\"onelobby: AudioEncoder\", e),\n });\n enc.configure({\n codec: \"opus\",\n sampleRate: SAMPLE_RATE,\n numberOfChannels: 1,\n bitrate: opts.bitrate,\n opus: { frameDuration: 20000, application: \"voip\", useinbandfec: true, usedtx: false, packetlossperc: 10 },\n } as AudioEncoderConfig);\n return {\n encode(pcm) {\n const data = new AudioData({\n format: \"f32\",\n sampleRate: SAMPLE_RATE,\n numberOfFrames: pcm.length,\n numberOfChannels: 1,\n timestamp: ts,\n data: pcm as unknown as Float32Array<ArrayBuffer>,\n });\n ts += 20000;\n enc.encode(data);\n data.close();\n },\n close() {\n if (enc.state !== \"closed\") enc.close();\n },\n };\n },\n createDecoder(onPcm): VoiceDecoderHandle {\n let ts = 0;\n const dec = new AudioDecoder({\n output: (audio) => {\n const pcm = new Float32Array(audio.numberOfFrames);\n audio.copyTo(pcm, { planeIndex: 0, format: \"f32-planar\" } as AudioDataCopyToOptions);\n audio.close();\n onPcm(pcm);\n },\n error: (e) => console.error(\"onelobby: AudioDecoder\", e),\n });\n dec.configure({ codec: \"opus\", sampleRate: SAMPLE_RATE, numberOfChannels: 1 });\n return {\n decode(opus) {\n if (opus === null) {\n onPcm(new Float32Array(FRAME_SAMPLES)); // WebCodecs has no PLC hook: conceal with silence\n return;\n }\n dec.decode(new EncodedAudioChunk({ type: \"key\", timestamp: ts, data: opus }));\n ts += 20000;\n },\n close() {\n if (dec.state !== \"closed\") dec.close();\n },\n };\n },\n};\n\nconst WORKLET_SRC = `\nclass OneLobbyCapture extends AudioWorkletProcessor {\n constructor() { super(); this.buf = new Float32Array(${FRAME_SAMPLES}); this.n = 0; }\n process(inputs) {\n const ch = inputs[0] && inputs[0][0];\n if (ch) {\n for (let i = 0; i < ch.length; i++) {\n this.buf[this.n++] = ch[i];\n if (this.n === ${FRAME_SAMPLES}) {\n const out = this.buf; this.buf = new Float32Array(${FRAME_SAMPLES}); this.n = 0;\n this.port.postMessage(out, [out.buffer]);\n }\n }\n }\n return true;\n }\n}\nregisterProcessor('onelobby-capture', OneLobbyCapture);\n`;\n\n/** getUserMedia + AudioWorklet producing 960-sample frames at 48 kHz. Must be started from a user gesture. */\nexport class WorkletMic implements FrameSource {\n private ctx: AudioContext | null = null;\n private stream: MediaStream | null = null;\n\n async start(onFrame: (pcm: Float32Array) => void): Promise<void> {\n this.stream = await navigator.mediaDevices.getUserMedia({\n audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true },\n });\n this.ctx = new AudioContext({ sampleRate: SAMPLE_RATE });\n const url = URL.createObjectURL(new Blob([WORKLET_SRC], { type: \"text/javascript\" }));\n try {\n await this.ctx.audioWorklet.addModule(url);\n } finally {\n URL.revokeObjectURL(url);\n }\n const src = this.ctx.createMediaStreamSource(this.stream);\n const node = new AudioWorkletNode(this.ctx, \"onelobby-capture\", { numberOfInputs: 1, numberOfOutputs: 0 });\n node.port.onmessage = (e) => onFrame(e.data as Float32Array);\n src.connect(node);\n if (this.ctx.state === \"suspended\") await this.ctx.resume();\n }\n\n stop(): void {\n for (const t of this.stream?.getTracks() ?? []) t.stop();\n void this.ctx?.close();\n this.stream = null;\n this.ctx = null;\n }\n}\n\n/** Plays decoded PCM per speaker, scheduled back to back on an AudioContext. */\nexport class WebAudioSink implements AudioSink {\n private ctx = new AudioContext({ sampleRate: SAMPLE_RATE });\n private next = new Map<number, number>();\n\n constructor() {\n void this.ctx.resume();\n }\n\n play(peerId: number, pcm: Float32Array, gain: number): void {\n if (pcm.length === 0) return;\n const buf = this.ctx.createBuffer(1, pcm.length, SAMPLE_RATE);\n buf.copyToChannel(pcm as unknown as Float32Array<ArrayBuffer>, 0);\n const src = this.ctx.createBufferSource();\n src.buffer = buf;\n const g = this.ctx.createGain();\n g.gain.value = gain;\n src.connect(g).connect(this.ctx.destination);\n const now = this.ctx.currentTime;\n let at = this.next.get(peerId) ?? 0;\n if (at < now + 0.01) at = now + 0.02; // (re)started: small lead so scheduling never lands in the past\n src.start(at);\n this.next.set(peerId, at + pcm.length / SAMPLE_RATE);\n }\n\n close(): void {\n void this.ctx.close();\n }\n}\n"]}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure voice building blocks (DESIGN s.9a): packetizer, PTT/VAD gate, jitter buffer, host relay policy.
|
|
3
|
+
* No audio APIs here, so all of it is unit-testable.
|
|
4
|
+
*/
|
|
5
|
+
export declare const SAMPLE_RATE = 48000;
|
|
6
|
+
export declare const FRAME_MS = 20;
|
|
7
|
+
export declare const FRAME_SAMPLES: number;
|
|
8
|
+
export declare const FRAMES_PER_PACKET = 2;
|
|
9
|
+
export declare const MAX_VOICE_PACKET_BYTES = 1024;
|
|
10
|
+
export declare const MAX_SPEAKERS = 8;
|
|
11
|
+
export declare const MAX_PACKETS_PER_SEC = 30;
|
|
12
|
+
export declare const JITTER_MS = 60;
|
|
13
|
+
export declare const VAD_HANGOVER_MS = 300;
|
|
14
|
+
export declare const FLAG_END_OF_BURST = 1;
|
|
15
|
+
export interface VoicePacket {
|
|
16
|
+
seq: number;
|
|
17
|
+
flags: number;
|
|
18
|
+
frames: Uint8Array[];
|
|
19
|
+
}
|
|
20
|
+
/** Batches encoded 20 ms frames into 40 ms packets; `endBurst()` flushes the remainder with the end flag. */
|
|
21
|
+
export declare class VoicePacketizer {
|
|
22
|
+
private framesPerPacket;
|
|
23
|
+
private seq;
|
|
24
|
+
private buf;
|
|
25
|
+
constructor(framesPerPacket?: number);
|
|
26
|
+
push(frame: Uint8Array): VoicePacket | null;
|
|
27
|
+
/** Wrap already-encoded frames in the next packet (no batching). */
|
|
28
|
+
packet(frames: Uint8Array[], flags: number): VoicePacket;
|
|
29
|
+
/** Always emits a packet (possibly with zero frames) carrying the end-of-burst flag. */
|
|
30
|
+
endBurst(): VoicePacket;
|
|
31
|
+
get buffered(): number;
|
|
32
|
+
private take;
|
|
33
|
+
}
|
|
34
|
+
export declare function rms(pcm: Float32Array): number;
|
|
35
|
+
export type VoiceMode = "pushToTalk" | "voiceActivity";
|
|
36
|
+
/** Decides, per captured 20 ms frame, whether it is transmitted. VAD uses an RMS gate with a 300 ms hangover. */
|
|
37
|
+
export declare class VoiceGate {
|
|
38
|
+
mode: VoiceMode;
|
|
39
|
+
ptt: boolean;
|
|
40
|
+
vadThreshold: number;
|
|
41
|
+
private lastLoud;
|
|
42
|
+
private open;
|
|
43
|
+
/** Returns { send, edge } where edge is 'open' | 'close' | null on a transition. */
|
|
44
|
+
update(level: number, nowMs: number): {
|
|
45
|
+
send: boolean;
|
|
46
|
+
edge: "open" | "close" | null;
|
|
47
|
+
};
|
|
48
|
+
get isOpen(): boolean;
|
|
49
|
+
}
|
|
50
|
+
/** u16 sequence comparison with wraparound: positive when a is after b. */
|
|
51
|
+
export declare function seqDiff(a: number, b: number): number;
|
|
52
|
+
export type PullResult = {
|
|
53
|
+
kind: "frame";
|
|
54
|
+
opus: Uint8Array;
|
|
55
|
+
} | {
|
|
56
|
+
kind: "plc";
|
|
57
|
+
} | {
|
|
58
|
+
kind: "wait";
|
|
59
|
+
} | {
|
|
60
|
+
kind: "idle";
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Per-speaker jitter buffer. Reorders by `seq`, prefills 60 ms (3 frames) before playing, reports a lost packet as
|
|
64
|
+
* PLC frames when a later packet is already here, and flushes on the end-of-burst flag.
|
|
65
|
+
* `pull()` is called once per 20 ms by the playout clock.
|
|
66
|
+
*/
|
|
67
|
+
export declare class JitterBuffer {
|
|
68
|
+
private framesPerPacket;
|
|
69
|
+
private packets;
|
|
70
|
+
private expected;
|
|
71
|
+
private ready;
|
|
72
|
+
private playing;
|
|
73
|
+
private endSeq;
|
|
74
|
+
private waitTicks;
|
|
75
|
+
readonly prefillFrames: number;
|
|
76
|
+
constructor(jitterMs?: number, framesPerPacket?: number);
|
|
77
|
+
push(p: VoicePacket): void;
|
|
78
|
+
private get ended();
|
|
79
|
+
private bufferedFrames;
|
|
80
|
+
private newerThanExpected;
|
|
81
|
+
pull(): PullResult;
|
|
82
|
+
private refill;
|
|
83
|
+
reset(): void;
|
|
84
|
+
get active(): boolean;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Host-side policy for forwarding voice (s.9a): <= 8 concurrent speakers (9th burst silently dropped), packets <= 1 KiB,
|
|
88
|
+
* <= 30 packets/s per peer.
|
|
89
|
+
*/
|
|
90
|
+
export declare class VoiceRelayPolicy {
|
|
91
|
+
private speakers;
|
|
92
|
+
private windows;
|
|
93
|
+
/** A burst with no packet for this long counts as over even if its end flag was lost. */
|
|
94
|
+
burstTimeoutMs: number;
|
|
95
|
+
accept(from: number, size: number, flags: number, now: number): boolean;
|
|
96
|
+
get activeSpeakers(): number;
|
|
97
|
+
forget(peerId: number): void;
|
|
98
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure voice building blocks (DESIGN s.9a): packetizer, PTT/VAD gate, jitter buffer, host relay policy.
|
|
3
|
+
* No audio APIs here, so all of it is unit-testable.
|
|
4
|
+
*/
|
|
5
|
+
export const SAMPLE_RATE = 48000;
|
|
6
|
+
export const FRAME_MS = 20;
|
|
7
|
+
export const FRAME_SAMPLES = (SAMPLE_RATE * FRAME_MS) / 1000; // 960
|
|
8
|
+
export const FRAMES_PER_PACKET = 2;
|
|
9
|
+
export const MAX_VOICE_PACKET_BYTES = 1024;
|
|
10
|
+
export const MAX_SPEAKERS = 8;
|
|
11
|
+
export const MAX_PACKETS_PER_SEC = 30;
|
|
12
|
+
export const JITTER_MS = 60;
|
|
13
|
+
export const VAD_HANGOVER_MS = 300;
|
|
14
|
+
export const FLAG_END_OF_BURST = 1;
|
|
15
|
+
/** Batches encoded 20 ms frames into 40 ms packets; `endBurst()` flushes the remainder with the end flag. */
|
|
16
|
+
export class VoicePacketizer {
|
|
17
|
+
framesPerPacket;
|
|
18
|
+
seq = 0;
|
|
19
|
+
buf = [];
|
|
20
|
+
constructor(framesPerPacket = FRAMES_PER_PACKET) {
|
|
21
|
+
this.framesPerPacket = framesPerPacket;
|
|
22
|
+
}
|
|
23
|
+
push(frame) {
|
|
24
|
+
this.buf.push(frame);
|
|
25
|
+
if (this.buf.length < this.framesPerPacket)
|
|
26
|
+
return null;
|
|
27
|
+
return this.take(0);
|
|
28
|
+
}
|
|
29
|
+
/** Wrap already-encoded frames in the next packet (no batching). */
|
|
30
|
+
packet(frames, flags) {
|
|
31
|
+
const p = { seq: this.seq, flags, frames };
|
|
32
|
+
this.seq = (this.seq + 1) & 0xffff;
|
|
33
|
+
return p;
|
|
34
|
+
}
|
|
35
|
+
/** Always emits a packet (possibly with zero frames) carrying the end-of-burst flag. */
|
|
36
|
+
endBurst() {
|
|
37
|
+
return this.take(FLAG_END_OF_BURST);
|
|
38
|
+
}
|
|
39
|
+
get buffered() {
|
|
40
|
+
return this.buf.length;
|
|
41
|
+
}
|
|
42
|
+
take(flags) {
|
|
43
|
+
const p = { seq: this.seq, flags, frames: this.buf };
|
|
44
|
+
this.seq = (this.seq + 1) & 0xffff;
|
|
45
|
+
this.buf = [];
|
|
46
|
+
return p;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function rms(pcm) {
|
|
50
|
+
if (pcm.length === 0)
|
|
51
|
+
return 0;
|
|
52
|
+
let s = 0;
|
|
53
|
+
for (let i = 0; i < pcm.length; i++)
|
|
54
|
+
s += pcm[i] * pcm[i];
|
|
55
|
+
return Math.sqrt(s / pcm.length);
|
|
56
|
+
}
|
|
57
|
+
/** Decides, per captured 20 ms frame, whether it is transmitted. VAD uses an RMS gate with a 300 ms hangover. */
|
|
58
|
+
export class VoiceGate {
|
|
59
|
+
mode = "pushToTalk";
|
|
60
|
+
ptt = false;
|
|
61
|
+
vadThreshold = 0.02;
|
|
62
|
+
lastLoud = -Infinity;
|
|
63
|
+
open = false;
|
|
64
|
+
/** Returns { send, edge } where edge is 'open' | 'close' | null on a transition. */
|
|
65
|
+
update(level, nowMs) {
|
|
66
|
+
let want;
|
|
67
|
+
if (this.mode === "pushToTalk") {
|
|
68
|
+
want = this.ptt;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
if (level >= this.vadThreshold)
|
|
72
|
+
this.lastLoud = nowMs;
|
|
73
|
+
want = nowMs - this.lastLoud <= VAD_HANGOVER_MS;
|
|
74
|
+
}
|
|
75
|
+
const edge = want === this.open ? null : want ? "open" : "close";
|
|
76
|
+
this.open = want;
|
|
77
|
+
return { send: want, edge };
|
|
78
|
+
}
|
|
79
|
+
get isOpen() {
|
|
80
|
+
return this.open;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** u16 sequence comparison with wraparound: positive when a is after b. */
|
|
84
|
+
export function seqDiff(a, b) {
|
|
85
|
+
const d = (a - b) & 0xffff;
|
|
86
|
+
return d >= 0x8000 ? d - 0x10000 : d;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Per-speaker jitter buffer. Reorders by `seq`, prefills 60 ms (3 frames) before playing, reports a lost packet as
|
|
90
|
+
* PLC frames when a later packet is already here, and flushes on the end-of-burst flag.
|
|
91
|
+
* `pull()` is called once per 20 ms by the playout clock.
|
|
92
|
+
*/
|
|
93
|
+
export class JitterBuffer {
|
|
94
|
+
framesPerPacket;
|
|
95
|
+
packets = new Map();
|
|
96
|
+
expected = null;
|
|
97
|
+
ready = [];
|
|
98
|
+
playing = false;
|
|
99
|
+
endSeq = null;
|
|
100
|
+
waitTicks = 0;
|
|
101
|
+
prefillFrames;
|
|
102
|
+
constructor(jitterMs = JITTER_MS, framesPerPacket = FRAMES_PER_PACKET) {
|
|
103
|
+
this.framesPerPacket = framesPerPacket;
|
|
104
|
+
this.prefillFrames = Math.max(1, Math.round(jitterMs / FRAME_MS));
|
|
105
|
+
}
|
|
106
|
+
push(p) {
|
|
107
|
+
if (this.expected === null) {
|
|
108
|
+
this.expected = p.seq;
|
|
109
|
+
}
|
|
110
|
+
else if (seqDiff(p.seq, this.expected) < 0) {
|
|
111
|
+
return; // late: already played or skipped
|
|
112
|
+
}
|
|
113
|
+
this.packets.set(p.seq, p);
|
|
114
|
+
if (p.flags & FLAG_END_OF_BURST)
|
|
115
|
+
this.endSeq = p.seq;
|
|
116
|
+
else if (this.endSeq !== null && seqDiff(p.seq, this.endSeq) > 0)
|
|
117
|
+
this.endSeq = null; // a new burst began
|
|
118
|
+
}
|
|
119
|
+
get ended() {
|
|
120
|
+
return this.endSeq !== null;
|
|
121
|
+
}
|
|
122
|
+
bufferedFrames() {
|
|
123
|
+
let n = this.ready.length;
|
|
124
|
+
for (const p of this.packets.values())
|
|
125
|
+
n += p.frames.length;
|
|
126
|
+
return n;
|
|
127
|
+
}
|
|
128
|
+
newerThanExpected() {
|
|
129
|
+
for (const s of this.packets.keys())
|
|
130
|
+
if (this.expected !== null && seqDiff(s, this.expected) > 0)
|
|
131
|
+
return true;
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
pull() {
|
|
135
|
+
if (this.expected === null)
|
|
136
|
+
return { kind: "idle" };
|
|
137
|
+
if (!this.playing) {
|
|
138
|
+
if (this.bufferedFrames() >= this.prefillFrames || (this.ended && this.packets.size > 0))
|
|
139
|
+
this.playing = true;
|
|
140
|
+
else
|
|
141
|
+
return { kind: "wait" };
|
|
142
|
+
}
|
|
143
|
+
if (this.ready.length === 0)
|
|
144
|
+
this.refill();
|
|
145
|
+
const f = this.ready.shift();
|
|
146
|
+
if (f) {
|
|
147
|
+
this.waitTicks = 0;
|
|
148
|
+
return f.length === 0 ? { kind: "plc" } : { kind: "frame", opus: f };
|
|
149
|
+
}
|
|
150
|
+
// nothing playable
|
|
151
|
+
if (this.ended && this.packets.size === 0) {
|
|
152
|
+
this.reset();
|
|
153
|
+
return { kind: "idle" };
|
|
154
|
+
}
|
|
155
|
+
if (++this.waitTicks > 12) {
|
|
156
|
+
// 240 ms of silence without an end flag: the burst is over (flag lost). Flush.
|
|
157
|
+
this.reset();
|
|
158
|
+
return { kind: "idle" };
|
|
159
|
+
}
|
|
160
|
+
return { kind: "wait" };
|
|
161
|
+
}
|
|
162
|
+
refill() {
|
|
163
|
+
if (this.expected === null)
|
|
164
|
+
return;
|
|
165
|
+
const p = this.packets.get(this.expected);
|
|
166
|
+
if (p) {
|
|
167
|
+
this.packets.delete(this.expected);
|
|
168
|
+
this.expected = (this.expected + 1) & 0xffff;
|
|
169
|
+
for (const fr of p.frames)
|
|
170
|
+
this.ready.push(fr);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (this.newerThanExpected()) {
|
|
174
|
+
// The packet never arrived but later ones did: conceal it (empty array = PLC marker).
|
|
175
|
+
this.expected = (this.expected + 1) & 0xffff;
|
|
176
|
+
for (let i = 0; i < this.framesPerPacket; i++)
|
|
177
|
+
this.ready.push(new Uint8Array(0));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
reset() {
|
|
181
|
+
this.packets.clear();
|
|
182
|
+
this.ready = [];
|
|
183
|
+
this.expected = null;
|
|
184
|
+
this.playing = false;
|
|
185
|
+
this.endSeq = null;
|
|
186
|
+
this.waitTicks = 0;
|
|
187
|
+
}
|
|
188
|
+
get active() {
|
|
189
|
+
return this.expected !== null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Host-side policy for forwarding voice (s.9a): <= 8 concurrent speakers (9th burst silently dropped), packets <= 1 KiB,
|
|
194
|
+
* <= 30 packets/s per peer.
|
|
195
|
+
*/
|
|
196
|
+
export class VoiceRelayPolicy {
|
|
197
|
+
speakers = new Map(); // peerId -> last packet time
|
|
198
|
+
windows = new Map();
|
|
199
|
+
/** A burst with no packet for this long counts as over even if its end flag was lost. */
|
|
200
|
+
burstTimeoutMs = 500;
|
|
201
|
+
accept(from, size, flags, now) {
|
|
202
|
+
if (size > MAX_VOICE_PACKET_BYTES)
|
|
203
|
+
return false;
|
|
204
|
+
// rate cap
|
|
205
|
+
let w = this.windows.get(from);
|
|
206
|
+
if (!w)
|
|
207
|
+
this.windows.set(from, (w = []));
|
|
208
|
+
while (w.length && now - w[0] >= 1000)
|
|
209
|
+
w.shift();
|
|
210
|
+
if (w.length >= MAX_PACKETS_PER_SEC)
|
|
211
|
+
return false;
|
|
212
|
+
// speaker cap
|
|
213
|
+
for (const [p, t] of this.speakers)
|
|
214
|
+
if (now - t > this.burstTimeoutMs)
|
|
215
|
+
this.speakers.delete(p);
|
|
216
|
+
if (!this.speakers.has(from) && this.speakers.size >= MAX_SPEAKERS)
|
|
217
|
+
return false;
|
|
218
|
+
w.push(now);
|
|
219
|
+
if (flags & FLAG_END_OF_BURST)
|
|
220
|
+
this.speakers.delete(from);
|
|
221
|
+
else
|
|
222
|
+
this.speakers.set(from, now);
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
get activeSpeakers() {
|
|
226
|
+
return this.speakers.size;
|
|
227
|
+
}
|
|
228
|
+
forget(peerId) {
|
|
229
|
+
this.speakers.delete(peerId);
|
|
230
|
+
this.windows.delete(peerId);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
//# sourceMappingURL=voice-core.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"voice-core.js","sourceRoot":"","sources":["../src/voice-core.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Pure voice building blocks (DESIGN s.9a): packetizer, PTT/VAD gate, jitter buffer, host relay policy.\n * No audio APIs here, so all of it is unit-testable.\n */\n\nexport const SAMPLE_RATE = 48000;\nexport const FRAME_MS = 20;\nexport const FRAME_SAMPLES = (SAMPLE_RATE * FRAME_MS) / 1000; // 960\nexport const FRAMES_PER_PACKET = 2;\nexport const MAX_VOICE_PACKET_BYTES = 1024;\nexport const MAX_SPEAKERS = 8;\nexport const MAX_PACKETS_PER_SEC = 30;\nexport const JITTER_MS = 60;\nexport const VAD_HANGOVER_MS = 300;\nexport const FLAG_END_OF_BURST = 1;\n\nexport interface VoicePacket {\n seq: number;\n flags: number;\n frames: Uint8Array[];\n}\n\n/** Batches encoded 20 ms frames into 40 ms packets; `endBurst()` flushes the remainder with the end flag. */\nexport class VoicePacketizer {\n private seq = 0;\n private buf: Uint8Array[] = [];\n constructor(private framesPerPacket = FRAMES_PER_PACKET) {}\n\n push(frame: Uint8Array): VoicePacket | null {\n this.buf.push(frame);\n if (this.buf.length < this.framesPerPacket) return null;\n return this.take(0);\n }\n\n /** Wrap already-encoded frames in the next packet (no batching). */\n packet(frames: Uint8Array[], flags: number): VoicePacket {\n const p: VoicePacket = { seq: this.seq, flags, frames };\n this.seq = (this.seq + 1) & 0xffff;\n return p;\n }\n\n /** Always emits a packet (possibly with zero frames) carrying the end-of-burst flag. */\n endBurst(): VoicePacket {\n return this.take(FLAG_END_OF_BURST);\n }\n\n get buffered(): number {\n return this.buf.length;\n }\n\n private take(flags: number): VoicePacket {\n const p: VoicePacket = { seq: this.seq, flags, frames: this.buf };\n this.seq = (this.seq + 1) & 0xffff;\n this.buf = [];\n return p;\n }\n}\n\nexport function rms(pcm: Float32Array): number {\n if (pcm.length === 0) return 0;\n let s = 0;\n for (let i = 0; i < pcm.length; i++) s += pcm[i] * pcm[i];\n return Math.sqrt(s / pcm.length);\n}\n\nexport type VoiceMode = \"pushToTalk\" | \"voiceActivity\";\n\n/** Decides, per captured 20 ms frame, whether it is transmitted. VAD uses an RMS gate with a 300 ms hangover. */\nexport class VoiceGate {\n mode: VoiceMode = \"pushToTalk\";\n ptt = false;\n vadThreshold = 0.02;\n private lastLoud = -Infinity;\n private open = false;\n\n /** Returns { send, edge } where edge is 'open' | 'close' | null on a transition. */\n update(level: number, nowMs: number): { send: boolean; edge: \"open\" | \"close\" | null } {\n let want: boolean;\n if (this.mode === \"pushToTalk\") {\n want = this.ptt;\n } else {\n if (level >= this.vadThreshold) this.lastLoud = nowMs;\n want = nowMs - this.lastLoud <= VAD_HANGOVER_MS;\n }\n const edge = want === this.open ? null : want ? \"open\" : \"close\";\n this.open = want;\n return { send: want, edge };\n }\n\n get isOpen(): boolean {\n return this.open;\n }\n}\n\n/** u16 sequence comparison with wraparound: positive when a is after b. */\nexport function seqDiff(a: number, b: number): number {\n const d = (a - b) & 0xffff;\n return d >= 0x8000 ? d - 0x10000 : d;\n}\n\nexport type PullResult =\n | { kind: \"frame\"; opus: Uint8Array }\n | { kind: \"plc\" }\n | { kind: \"wait\" }\n | { kind: \"idle\" };\n\n/**\n * Per-speaker jitter buffer. Reorders by `seq`, prefills 60 ms (3 frames) before playing, reports a lost packet as\n * PLC frames when a later packet is already here, and flushes on the end-of-burst flag.\n * `pull()` is called once per 20 ms by the playout clock.\n */\nexport class JitterBuffer {\n private packets = new Map<number, VoicePacket>();\n private expected: number | null = null;\n private ready: Uint8Array[] = [];\n private playing = false;\n private endSeq: number | null = null;\n private waitTicks = 0;\n readonly prefillFrames: number;\n\n constructor(jitterMs = JITTER_MS, private framesPerPacket = FRAMES_PER_PACKET) {\n this.prefillFrames = Math.max(1, Math.round(jitterMs / FRAME_MS));\n }\n\n push(p: VoicePacket): void {\n if (this.expected === null) {\n this.expected = p.seq;\n } else if (seqDiff(p.seq, this.expected) < 0) {\n return; // late: already played or skipped\n }\n this.packets.set(p.seq, p);\n if (p.flags & FLAG_END_OF_BURST) this.endSeq = p.seq;\n else if (this.endSeq !== null && seqDiff(p.seq, this.endSeq) > 0) this.endSeq = null; // a new burst began\n }\n\n private get ended(): boolean {\n return this.endSeq !== null;\n }\n\n private bufferedFrames(): number {\n let n = this.ready.length;\n for (const p of this.packets.values()) n += p.frames.length;\n return n;\n }\n\n private newerThanExpected(): boolean {\n for (const s of this.packets.keys()) if (this.expected !== null && seqDiff(s, this.expected) > 0) return true;\n return false;\n }\n\n pull(): PullResult {\n if (this.expected === null) return { kind: \"idle\" };\n if (!this.playing) {\n if (this.bufferedFrames() >= this.prefillFrames || (this.ended && this.packets.size > 0)) this.playing = true;\n else return { kind: \"wait\" };\n }\n if (this.ready.length === 0) this.refill();\n const f = this.ready.shift();\n if (f) {\n this.waitTicks = 0;\n return f.length === 0 ? { kind: \"plc\" } : { kind: \"frame\", opus: f };\n }\n // nothing playable\n if (this.ended && this.packets.size === 0) {\n this.reset();\n return { kind: \"idle\" };\n }\n if (++this.waitTicks > 12) {\n // 240 ms of silence without an end flag: the burst is over (flag lost). Flush.\n this.reset();\n return { kind: \"idle\" };\n }\n return { kind: \"wait\" };\n }\n\n private refill(): void {\n if (this.expected === null) return;\n const p = this.packets.get(this.expected);\n if (p) {\n this.packets.delete(this.expected);\n this.expected = (this.expected + 1) & 0xffff;\n for (const fr of p.frames) this.ready.push(fr);\n return;\n }\n if (this.newerThanExpected()) {\n // The packet never arrived but later ones did: conceal it (empty array = PLC marker).\n this.expected = (this.expected + 1) & 0xffff;\n for (let i = 0; i < this.framesPerPacket; i++) this.ready.push(new Uint8Array(0));\n }\n }\n\n reset(): void {\n this.packets.clear();\n this.ready = [];\n this.expected = null;\n this.playing = false;\n this.endSeq = null;\n this.waitTicks = 0;\n }\n\n get active(): boolean {\n return this.expected !== null;\n }\n}\n\n/**\n * Host-side policy for forwarding voice (s.9a): <= 8 concurrent speakers (9th burst silently dropped), packets <= 1 KiB,\n * <= 30 packets/s per peer.\n */\nexport class VoiceRelayPolicy {\n private speakers = new Map<number, number>(); // peerId -> last packet time\n private windows = new Map<number, number[]>();\n /** A burst with no packet for this long counts as over even if its end flag was lost. */\n burstTimeoutMs = 500;\n\n accept(from: number, size: number, flags: number, now: number): boolean {\n if (size > MAX_VOICE_PACKET_BYTES) return false;\n // rate cap\n let w = this.windows.get(from);\n if (!w) this.windows.set(from, (w = []));\n while (w.length && now - w[0] >= 1000) w.shift();\n if (w.length >= MAX_PACKETS_PER_SEC) return false;\n // speaker cap\n for (const [p, t] of this.speakers) if (now - t > this.burstTimeoutMs) this.speakers.delete(p);\n if (!this.speakers.has(from) && this.speakers.size >= MAX_SPEAKERS) return false;\n w.push(now);\n if (flags & FLAG_END_OF_BURST) this.speakers.delete(from);\n else this.speakers.set(from, now);\n return true;\n }\n\n get activeSpeakers(): number {\n return this.speakers.size;\n }\n\n forget(peerId: number): void {\n this.speakers.delete(peerId);\n this.windows.delete(peerId);\n }\n}\n"]}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Pluggable audio seams for voice: codec (WebCodecs or a WASM Opus), mic source, speaker sink. */
|
|
2
|
+
export interface VoiceError {
|
|
3
|
+
message: string;
|
|
4
|
+
cause?: unknown;
|
|
5
|
+
}
|
|
6
|
+
export interface VoiceEncoderHandle {
|
|
7
|
+
/** Encode one 20 ms mono 48 kHz frame (960 samples, float -1..1). Output arrives through the callback. */
|
|
8
|
+
encode(pcm: Float32Array): void;
|
|
9
|
+
close(): void;
|
|
10
|
+
}
|
|
11
|
+
export interface VoiceDecoderHandle {
|
|
12
|
+
/** Decode one Opus frame; `null` asks for packet-loss concealment of one 20 ms frame. */
|
|
13
|
+
decode(opus: Uint8Array | null): void;
|
|
14
|
+
close(): void;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Opus codec seam. The default implementation uses WebCodecs `AudioEncoder`/`AudioDecoder`; a WASM libopus build
|
|
18
|
+
* can implement this interface for browsers without WebCodecs Opus.
|
|
19
|
+
*/
|
|
20
|
+
export interface VoiceCodec {
|
|
21
|
+
createEncoder(opts: {
|
|
22
|
+
bitrate: number;
|
|
23
|
+
}, onFrame: (opus: Uint8Array) => void): VoiceEncoderHandle;
|
|
24
|
+
createDecoder(onPcm: (pcm: Float32Array) => void): VoiceDecoderHandle;
|
|
25
|
+
}
|
|
26
|
+
/** Microphone: delivers 960-sample frames. */
|
|
27
|
+
export interface FrameSource {
|
|
28
|
+
start(onFrame: (pcm: Float32Array) => void): Promise<void>;
|
|
29
|
+
stop(): void;
|
|
30
|
+
}
|
|
31
|
+
/** Speaker output for decoded PCM (mono 48 kHz). `gain` is 0..2. */
|
|
32
|
+
export interface AudioSink {
|
|
33
|
+
play(peerId: number, pcm: Float32Array, gain: number): void;
|
|
34
|
+
close(): void;
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"voice-types.js","sourceRoot":"","sources":["../src/voice-types.ts"],"names":[],"mappings":"AAAA,mGAAmG","sourcesContent":["/** Pluggable audio seams for voice: codec (WebCodecs or a WASM Opus), mic source, speaker sink. */\n\nexport interface VoiceError {\n message: string;\n cause?: unknown;\n}\n\nexport interface VoiceEncoderHandle {\n /** Encode one 20 ms mono 48 kHz frame (960 samples, float -1..1). Output arrives through the callback. */\n encode(pcm: Float32Array): void;\n close(): void;\n}\n\nexport interface VoiceDecoderHandle {\n /** Decode one Opus frame; `null` asks for packet-loss concealment of one 20 ms frame. */\n decode(opus: Uint8Array | null): void;\n close(): void;\n}\n\n/**\n * Opus codec seam. The default implementation uses WebCodecs `AudioEncoder`/`AudioDecoder`; a WASM libopus build\n * can implement this interface for browsers without WebCodecs Opus.\n */\nexport interface VoiceCodec {\n createEncoder(opts: { bitrate: number }, onFrame: (opus: Uint8Array) => void): VoiceEncoderHandle;\n createDecoder(onPcm: (pcm: Float32Array) => void): VoiceDecoderHandle;\n}\n\n/** Microphone: delivers 960-sample frames. */\nexport interface FrameSource {\n start(onFrame: (pcm: Float32Array) => void): Promise<void>;\n stop(): void;\n}\n\n/** Speaker output for decoded PCM (mono 48 kHz). `gain` is 0..2. */\nexport interface AudioSink {\n play(peerId: number, pcm: Float32Array, gain: number): void;\n close(): void;\n}\n"]}
|
package/dist/voice.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { VoiceError } from "./voice-types.js";
|
|
2
|
+
import { Emitter } from "./emitter.js";
|
|
3
|
+
import type { Hub } from "./hub.js";
|
|
4
|
+
import type { Message } from "./codec.js";
|
|
5
|
+
import { type VoiceMode, type VoicePacket } from "./voice-core.js";
|
|
6
|
+
import type { AudioSink, FrameSource, VoiceCodec } from "./voice-types.js";
|
|
7
|
+
export type VoiceEvents = {
|
|
8
|
+
speaking: [peerId: number, talking: boolean];
|
|
9
|
+
error: [VoiceError];
|
|
10
|
+
/** Every voice packet that reached this peer (after the host's validity checks), before mute/enable filtering. */
|
|
11
|
+
packet: [speaker: number, seq: number, flags: number, frames: Uint8Array[]];
|
|
12
|
+
};
|
|
13
|
+
export interface VoiceDeps {
|
|
14
|
+
codec: VoiceCodec | null;
|
|
15
|
+
/** Creates the mic capture; resolved on `enabled = true`. */
|
|
16
|
+
createSource: (() => FrameSource) | null;
|
|
17
|
+
createSink: (() => AudioSink) | null;
|
|
18
|
+
}
|
|
19
|
+
/** Voice chat (DESIGN s.9a): Opus frames on the `voice` channel, star-routed through the host, never in a frame. */
|
|
20
|
+
export declare class Voice extends Emitter<VoiceEvents> {
|
|
21
|
+
private hub;
|
|
22
|
+
private deps;
|
|
23
|
+
mode: VoiceMode;
|
|
24
|
+
/** Master mute for capture (self). */
|
|
25
|
+
muted: boolean;
|
|
26
|
+
/** 0..1 RMS of the last captured frame. */
|
|
27
|
+
inputLevel: number;
|
|
28
|
+
bitrate: number;
|
|
29
|
+
private _enabled;
|
|
30
|
+
private gate;
|
|
31
|
+
private packetizer;
|
|
32
|
+
private relay;
|
|
33
|
+
private speakers;
|
|
34
|
+
private peerMuted;
|
|
35
|
+
private gains;
|
|
36
|
+
private encoder;
|
|
37
|
+
private source;
|
|
38
|
+
private sink;
|
|
39
|
+
private cancelPlayout;
|
|
40
|
+
private endTimer;
|
|
41
|
+
private sending;
|
|
42
|
+
constructor(hub: Hub, deps: VoiceDeps);
|
|
43
|
+
get enabled(): boolean;
|
|
44
|
+
set enabled(v: boolean);
|
|
45
|
+
pushToTalk(down: boolean): void;
|
|
46
|
+
isMutedPeer(peerId: number): boolean;
|
|
47
|
+
setMuted(peerId: number, muted: boolean): void;
|
|
48
|
+
setVolume(peerId: number, gain: number): void;
|
|
49
|
+
volumeOf(peerId: number): number;
|
|
50
|
+
private start;
|
|
51
|
+
private stop;
|
|
52
|
+
/** Feed one 20 ms mono 48 kHz frame (called by the mic source; exposed so tests and custom sources can drive it). */
|
|
53
|
+
onCapturedFrame(pcm: Float32Array, nowMs: number): void;
|
|
54
|
+
private closeBurst;
|
|
55
|
+
/** Send already-encoded Opus frames as one packet (no codec involved); used by tools and conformance. */
|
|
56
|
+
sendEncoded(frames: Uint8Array[], endOfBurst?: boolean): void;
|
|
57
|
+
/** Encoded frame from the encoder (also the test entry point for packetization). */
|
|
58
|
+
onEncoded(opus: Uint8Array): void;
|
|
59
|
+
private sendPacket;
|
|
60
|
+
handle(from: number, route: number, msg: Message): void;
|
|
61
|
+
/** Local playback path for one speaker's packet (after host forwarding). */
|
|
62
|
+
receivePacket(speaker: number, p: VoicePacket, now: number): void;
|
|
63
|
+
private playoutTick;
|
|
64
|
+
onPeerLeft(peerId: number): void;
|
|
65
|
+
close(): void;
|
|
66
|
+
/** For tests: expose the end-of-burst flag constant. */
|
|
67
|
+
static readonly END = 1;
|
|
68
|
+
}
|