secutorpro commited on
Commit
b5200e2
·
verified ·
1 Parent(s): 0342026

PTT audio: AudioWorklet PCM capture, playback queue, SOS alarm

Browse files
Files changed (1) hide show
  1. web/src/audio.js +97 -0
web/src/audio.js ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // PTT audio pipeline.
2
+ // Capture: AudioWorklet reads mic at 16 kHz, posts Int16 PCM chunks to the main
3
+ // thread, which sends them over the WebSocket. Playback: incoming PCM chunks are
4
+ // queued as AudioBuffers and played back-to-back.
5
+
6
+ const WORKLET_SRC = `
7
+ class PCMCapture extends AudioWorkletProcessor {
8
+ process(inputs) {
9
+ const input = inputs[0];
10
+ if (input && input[0] && input[0].length) {
11
+ const f32 = input[0];
12
+ const i16 = new Int16Array(f32.length);
13
+ for (let i = 0; i < f32.length; i++) {
14
+ const s = Math.max(-1, Math.min(1, f32[i]));
15
+ i16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
16
+ }
17
+ this.port.postMessage(i16.buffer, [i16.buffer]);
18
+ }
19
+ return true;
20
+ }
21
+ }
22
+ registerProcessor('pcm-capture', PCMCapture);
23
+ `;
24
+
25
+ function makeCtx() {
26
+ try { return new AudioContext({ sampleRate: 16000 }); }
27
+ catch { return new AudioContext(); }
28
+ }
29
+
30
+ export async function startCapture(onChunk) {
31
+ const stream = await navigator.mediaDevices.getUserMedia({
32
+ audio: { echoCancellation: true, noiseSuppression: true },
33
+ });
34
+ const ctx = makeCtx();
35
+ const url = URL.createObjectURL(new Blob([WORKLET_SRC], { type: 'application/javascript' }));
36
+ await ctx.audioWorklet.addModule(url);
37
+ URL.revokeObjectURL(url);
38
+
39
+ const source = ctx.createMediaStreamSource(stream);
40
+ const node = new AudioWorkletNode(ctx, 'pcm-capture');
41
+ node.port.onmessage = (e) => onChunk(e.data);
42
+ source.connect(node); // deliberately NOT connected to ctx.destination: no local feedback
43
+
44
+ return {
45
+ stop() {
46
+ node.port.onmessage = null;
47
+ try { source.disconnect(); } catch { /* already closed */ }
48
+ stream.getTracks().forEach((t) => t.stop());
49
+ ctx.close().catch(() => {});
50
+ },
51
+ };
52
+ }
53
+
54
+ export class PttPlayer {
55
+ constructor() {
56
+ this.ctx = makeCtx();
57
+ this.nextStart = 0;
58
+ }
59
+
60
+ play(pcmBuffer) {
61
+ const i16 = new Int16Array(pcmBuffer);
62
+ if (!i16.length) return;
63
+ const f32 = new Float32Array(i16.length);
64
+ for (let i = 0; i < i16.length; i++) f32[i] = i16[i] / 0x8000;
65
+ const buffer = this.ctx.createBuffer(1, f32.length, 16000);
66
+ buffer.copyToChannel(f32, 0);
67
+ const src = this.ctx.createBufferSource();
68
+ src.buffer = buffer;
69
+ src.connect(this.ctx.destination);
70
+ const now = this.ctx.currentTime;
71
+ if (this.nextStart < now) this.nextStart = now + 0.03;
72
+ src.start(this.nextStart);
73
+ this.nextStart += buffer.duration;
74
+ }
75
+
76
+ reset() { this.nextStart = 0; }
77
+ }
78
+
79
+ export function sosBeep(times = 3) {
80
+ let ctx;
81
+ try { ctx = new AudioContext(); } catch { return; }
82
+ const t0 = ctx.currentTime;
83
+ for (let i = 0; i < times; i++) {
84
+ const osc = ctx.createOscillator();
85
+ const gain = ctx.createGain();
86
+ osc.type = 'square';
87
+ osc.frequency.value = 880;
88
+ const s = t0 + i * 0.35;
89
+ gain.gain.setValueAtTime(0.0001, s);
90
+ gain.gain.exponentialRampToValueAtTime(0.2, s + 0.02);
91
+ gain.gain.exponentialRampToValueAtTime(0.0001, s + 0.3);
92
+ osc.connect(gain).connect(ctx.destination);
93
+ osc.start(s);
94
+ osc.stop(s + 0.32);
95
+ }
96
+ setTimeout(() => ctx.close().catch(() => {}), times * 350 + 500);
97
+ }