File size: 5,426 Bytes
5ca6cf1
 
 
 
 
 
6078ab4
5ca6cf1
8172156
0a4ae64
a5b2d49
 
 
5ca6cf1
 
8172156
 
 
 
 
5ca6cf1
cecec00
5ca6cf1
 
6078ab4
5ca6cf1
0a4ae64
a5b2d49
5ca6cf1
 
0a4ae64
5ca6cf1
 
a5b2d49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ca6cf1
 
 
 
 
8172156
5ca6cf1
 
 
 
6078ab4
 
 
 
 
 
 
 
 
0a4ae64
6078ab4
 
0a4ae64
7fb5553
 
6078ab4
7fb5553
 
6078ab4
 
 
7fb5553
 
 
 
 
 
 
0a4ae64
5ca6cf1
 
 
cecec00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ca6cf1
 
 
 
 
 
 
 
 
 
6078ab4
5ca6cf1
0a4ae64
a5b2d49
5ca6cf1
 
 
 
 
 
 
 
 
 
 
6078ab4
5ca6cf1
 
 
 
 
 
 
 
 
 
 
 
 
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();
}