File size: 3,153 Bytes
29393b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
/**
 * WO-2 Pipeline 1, T2 — browser audio session state.
 *
 * Holds each connected browser's PCM audio entirely in this process's
 * memory, the same pattern run.mjs already uses for eveChat/nodeChats.
 * Deliberately does NOT touch /dev/shm/miranda_bus — Pipeline 1's audio
 * never needs to cross into the WO-1 ring buffer. See
 * .kiro/specs/wo2-acoustic-ingress-routing/tasks.md, "Architectural
 * resolution #2" for the reasoning: that ring buffer is WO-1's contract for
 * the Rust-native Pipeline 2 path (native cpal capture), not for browser
 * audio arriving over a WebSocket.
 *
 * Expected wire format: binary WebSocket frames, each frame a
 * little-endian Float32Array of PCM samples at 16 kHz mono. The browser
 * side (client-apps/web) sends 160-sample (10 ms) frames to match WO-1's
 * AudioChunk.samples size, but this module does not assume a fixed frame
 * size — it just accumulates whatever arrives until a caller asks for the
 * buffered audio (T3 will do this on speech-end).
 */

/** One browser connection's accumulated audio state. */
class AudioSession {
  constructor(id) {
    this.id = id;
    /** @type {Float32Array[]} raw frames, oldest first */
    this.frames = [];
    this.totalSamples = 0;
    this.createdAt = Date.now();
    this.lastFrameAt = null;
  }

  /**
   * Accepts one binary WS frame (a Node Buffer) and appends it as PCM.
   * Returns the number of samples in this frame, for logging/telemetry.
   */
  pushFrame(buf) {
    if (buf.length % 4 !== 0) {
      throw new Error(
        `audio frame length ${buf.length} is not a multiple of 4 bytes (Float32)`,
      );
    }
    // Float32Array requires its buffer's byteOffset to be 4-byte aligned.
    // A Buffer view into a larger pooled allocation may not be — copy the
    // bytes out to guarantee correct alignment rather than assume it.
    const aligned = buf.byteOffset % 4 === 0 ? buf : Buffer.from(buf);
    const samples = new Float32Array(
      aligned.buffer,
      aligned.byteOffset,
      aligned.length / 4,
    );
    this.frames.push(samples);
    this.totalSamples += samples.length;
    this.lastFrameAt = Date.now();
    return samples.length;
  }

  /** Concatenates all buffered frames into one Float32Array and clears them. */
  drain() {
    const out = new Float32Array(this.totalSamples);
    let offset = 0;
    for (const frame of this.frames) {
      out.set(frame, offset);
      offset += frame.length;
    }
    this.frames = [];
    this.totalSamples = 0;
    return out;
  }

  /** Non-destructive peek at the current buffered sample count. */
  bufferedSamples() {
    return this.totalSamples;
  }
}

/** Registry of active audio sessions, keyed by an opaque connection id. */
export class AudioSessionRegistry {
  constructor() {
    /** @type {Map<string, AudioSession>} */
    this.sessions = new Map();
  }

  getOrCreate(id) {
    let session = this.sessions.get(id);
    if (!session) {
      session = new AudioSession(id);
      this.sessions.set(id, session);
    }
    return session;
  }

  get(id) {
    return this.sessions.get(id);
  }

  remove(id) {
    this.sessions.delete(id);
  }
}