File size: 5,426 Bytes
c0ddd13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
const DEFAULT_SAMPLE_RATE = 16000;

export class AudioPlayer {
  private context: AudioContext | null = null;
  private nextPlayTime = 0;
  private started = false;
  private resumed = false;
  private pendingBytes = 0;
  private bufferThresholdBytes = 6400; // 200ms at 16kHz default
  private pendingBuffers: AudioBuffer[] = [];
  // Carries the leftover byte when a chunk has odd byte length, so PCM sample
  // boundaries stay aligned across chunk splits from the HTTP stream.
  private leftoverByte: number | null = null;

  init(sampleRate = DEFAULT_SAMPLE_RATE): void {
    if (this.context) {
      if (this.context.sampleRate === sampleRate) return;
      this.context.close();
      this.context = null;
    }
    this.context = new AudioContext({ sampleRate });
    this.bufferThresholdBytes = Math.floor(sampleRate * 0.5) * 2; // 500ms
    this.nextPlayTime = 0;
    this.started = false;
    this.resumed = false;
    this.pendingBytes = 0;
    this.pendingBuffers = [];
    this.leftoverByte = null;
  }

  enqueue(rawPcm: ArrayBuffer, onStarted?: () => void): void {
    if (!this.context) return;

    // Prepend any leftover byte from the previous chunk so that Int16 sample
    // boundaries are always aligned, regardless of how the HTTP stream splits.
    let pcmBytes: Uint8Array;
    if (this.leftoverByte !== null) {
      const combined = new Uint8Array(1 + rawPcm.byteLength);
      combined[0] = this.leftoverByte;
      combined.set(new Uint8Array(rawPcm), 1);
      pcmBytes = combined;
      this.leftoverByte = null;
    } else {
      pcmBytes = new Uint8Array(rawPcm);
    }

    // If still odd, save the trailing byte for the next chunk.
    if (pcmBytes.byteLength % 2 !== 0) {
      this.leftoverByte = pcmBytes[pcmBytes.byteLength - 1];
      pcmBytes = pcmBytes.slice(0, pcmBytes.byteLength - 1);
    }

    if (pcmBytes.byteLength === 0) return;

    const int16 = new Int16Array(pcmBytes.buffer, pcmBytes.byteOffset, pcmBytes.byteLength / 2);
    const float32 = new Float32Array(int16.length);
    for (let i = 0; i < int16.length; i++) {
      float32[i] = int16[i] / 32768;
    }

    const audioBuffer = this.context.createBuffer(1, float32.length, this.context.sampleRate);
    audioBuffer.copyToChannel(float32, 0);

    this.pendingBytes += rawPcm.byteLength;

    if (this.resumed) {
      // AudioContext is running — schedule immediately
      const source = this.context.createBufferSource();
      source.buffer = audioBuffer;
      source.connect(this.context.destination);
      source.start(this.nextPlayTime);
      this.nextPlayTime += audioBuffer.duration;
    } else {
      // Still buffering: waiting for threshold or waiting for ctx.resume() to complete
      this.pendingBuffers.push(audioBuffer);

      if (!this.started && this.pendingBytes >= this.bufferThresholdBytes) {
        this.started = true;
        void this.context.resume().then(() => {
          if (!this.context) return;
          this.resumed = true;
          this.nextPlayTime = this.context.currentTime;
          onStarted?.();
          const toSchedule = this.pendingBuffers.splice(0);
          this.pendingBuffers = [];
          for (const buf of toSchedule) {
            const source = this.context.createBufferSource();
            source.buffer = buf;
            source.connect(this.context.destination);
            source.start(this.nextPlayTime);
            this.nextPlayTime += buf.duration;
          }
        });
      }
    }
  }

  // Call after the stream ends to play any buffered audio that hasn't started yet
  // (handles responses shorter than the buffer threshold)
  flush(onStarted?: () => void): void {
    if (!this.context || this.started || this.pendingBuffers.length === 0) return;
    this.started = true;
    void this.context.resume().then(() => {
      if (!this.context) return;
      this.resumed = true;
      this.nextPlayTime = this.context.currentTime;
      onStarted?.();
      const toSchedule = this.pendingBuffers.splice(0);
      this.pendingBuffers = [];
      for (const buf of toSchedule) {
        const source = this.context.createBufferSource();
        source.buffer = buf;
        source.connect(this.context.destination);
        source.start(this.nextPlayTime);
        this.nextPlayTime += buf.duration;
      }
    });
  }

  drain(): void {
    // Let queued buffers play out — nothing to do, the AudioContext schedule handles it
  }

  stopImmediately(): void {
    if (!this.context) return;
    this.context.close();
    this.context = null;
    this.nextPlayTime = 0;
    this.started = false;
    this.resumed = false;
    this.pendingBytes = 0;
    this.pendingBuffers = [];
    this.leftoverByte = null;
  }
}

export function replayAudio(
  chunks: ArrayBuffer[],
  sampleRate: number
): () => void {
  const ctx = new AudioContext({ sampleRate });
  let nextTime = ctx.currentTime + 0.05;

  for (const chunk of chunks) {
    const int16 = new Int16Array(chunk, 0, Math.floor(chunk.byteLength / 2));
    const float32 = new Float32Array(int16.length);
    for (let i = 0; i < int16.length; i++) float32[i] = int16[i] / 32768;
    const buf = ctx.createBuffer(1, float32.length, sampleRate);
    buf.copyToChannel(float32, 0);
    const src = ctx.createBufferSource();
    src.buffer = buf;
    src.connect(ctx.destination);
    src.start(nextTime);
    nextTime += buf.duration;
  }

  return () => ctx.close();
}