@lokutor/sdk 1.1.17 → 1.1.19
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/dist/index.d.mts +119 -4
- package/dist/index.d.ts +119 -4
- package/dist/index.js +387 -219
- package/dist/index.mjs +385 -63
- package/package.json +13 -4
- package/src/audio-utils.ts +253 -0
- package/src/browser-audio.ts +395 -0
- package/src/client.ts +886 -0
- package/src/index.ts +25 -0
- package/src/node-audio.ts +115 -0
- package/src/types.ts +270 -0
- package/dist/chunk-UI24THO7.mjs +0 -44
- package/dist/node-audio-5HOWE6MC.mjs +0 -94
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { AUDIO_CONFIG } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Audio utility functions for format conversion, resampling, and PCM processing
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Convert 16-bit PCM (Int16) to 32-bit Float
|
|
9
|
+
* @param int16Data Int16Array of PCM audio
|
|
10
|
+
* @returns Float32Array normalized to [-1, 1]
|
|
11
|
+
*/
|
|
12
|
+
export function pcm16ToFloat32(int16Data: Int16Array): Float32Array {
|
|
13
|
+
const float32 = new Float32Array(int16Data.length);
|
|
14
|
+
for (let i = 0; i < int16Data.length; i++) {
|
|
15
|
+
float32[i] = int16Data[i] / 32768.0;
|
|
16
|
+
}
|
|
17
|
+
return float32;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Convert 32-bit Float to 16-bit PCM (Int16)
|
|
22
|
+
* @param float32Data Float32Array normalized to [-1, 1]
|
|
23
|
+
* @returns Int16Array of PCM audio
|
|
24
|
+
*/
|
|
25
|
+
export function float32ToPcm16(float32Data: Float32Array): Int16Array {
|
|
26
|
+
const int16 = new Int16Array(float32Data.length);
|
|
27
|
+
for (let i = 0; i < float32Data.length; i++) {
|
|
28
|
+
// Clamp to [-1, 1]
|
|
29
|
+
const s = Math.max(-1, Math.min(1, float32Data[i]));
|
|
30
|
+
// Convert to Int16
|
|
31
|
+
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
|
32
|
+
}
|
|
33
|
+
return int16;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resample audio data from one sample rate to another using linear interpolation
|
|
38
|
+
* @param input Float32Array of input audio
|
|
39
|
+
* @param inputRate Original sample rate in Hz
|
|
40
|
+
* @param outputRate Target sample rate in Hz
|
|
41
|
+
* @returns Float32Array of resampled audio
|
|
42
|
+
*/
|
|
43
|
+
export function resample(
|
|
44
|
+
input: Float32Array,
|
|
45
|
+
inputRate: number,
|
|
46
|
+
outputRate: number
|
|
47
|
+
): Float32Array {
|
|
48
|
+
if (inputRate === outputRate) {
|
|
49
|
+
return new Float32Array(input);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ratio = inputRate / outputRate;
|
|
53
|
+
const outputLength = Math.round(input.length / ratio);
|
|
54
|
+
const output = new Float32Array(outputLength);
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < outputLength; i++) {
|
|
57
|
+
const pos = i * ratio;
|
|
58
|
+
const left = Math.floor(pos);
|
|
59
|
+
const right = Math.min(left + 1, input.length - 1);
|
|
60
|
+
const weight = pos - left;
|
|
61
|
+
|
|
62
|
+
// Linear interpolation
|
|
63
|
+
output[i] = input[left] * (1 - weight) + input[right] * weight;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return output;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Apply a simple low-pass filter for anti-aliasing during downsampling
|
|
71
|
+
* @param data Float32Array of audio
|
|
72
|
+
* @param cutoffFreq Cutoff frequency in Hz
|
|
73
|
+
* @param sampleRate Sample rate in Hz
|
|
74
|
+
* @returns Filtered Float32Array
|
|
75
|
+
*/
|
|
76
|
+
export function applyLowPassFilter(
|
|
77
|
+
data: Float32Array,
|
|
78
|
+
cutoffFreq: number,
|
|
79
|
+
sampleRate: number
|
|
80
|
+
): Float32Array {
|
|
81
|
+
// Calculate filter coefficient using first-order IIR filter
|
|
82
|
+
const dt = 1 / sampleRate;
|
|
83
|
+
const rc = 1 / (2 * Math.PI * cutoffFreq);
|
|
84
|
+
const alpha = dt / (rc + dt);
|
|
85
|
+
|
|
86
|
+
const filtered = new Float32Array(data.length);
|
|
87
|
+
filtered[0] = data[0];
|
|
88
|
+
|
|
89
|
+
for (let i = 1; i < data.length; i++) {
|
|
90
|
+
filtered[i] = filtered[i - 1] + alpha * (data[i] - filtered[i - 1]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return filtered;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resample audio with anti-aliasing low-pass filter
|
|
98
|
+
* Best used when downsampling to prevent aliasing artifacts
|
|
99
|
+
* @param input Float32Array of input audio
|
|
100
|
+
* @param inputRate Original sample rate in Hz
|
|
101
|
+
* @param outputRate Target sample rate in Hz
|
|
102
|
+
* @returns Float32Array of resampled and filtered audio
|
|
103
|
+
*/
|
|
104
|
+
export function resampleWithAntiAliasing(
|
|
105
|
+
input: Float32Array,
|
|
106
|
+
inputRate: number,
|
|
107
|
+
outputRate: number
|
|
108
|
+
): Float32Array {
|
|
109
|
+
if (inputRate === outputRate) {
|
|
110
|
+
return new Float32Array(input);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// If downsampling, apply low-pass filter first to prevent aliasing
|
|
114
|
+
let processed = input;
|
|
115
|
+
if (outputRate < inputRate) {
|
|
116
|
+
const nyquistFreq = outputRate / 2;
|
|
117
|
+
const cutoffFreq = nyquistFreq * 0.9; // 90% of Nyquist to be safe
|
|
118
|
+
processed = applyLowPassFilter(input, cutoffFreq, inputRate);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return resample(processed, inputRate, outputRate);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Convert raw audio samples to Uint8Array (bytes)
|
|
126
|
+
* @param data Int16Array of PCM audio
|
|
127
|
+
* @returns Uint8Array containing PCM bytes
|
|
128
|
+
*/
|
|
129
|
+
export function pcm16ToBytes(data: Int16Array): Uint8Array {
|
|
130
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Convert bytes to Int16Array
|
|
135
|
+
* @param bytes Uint8Array of PCM bytes
|
|
136
|
+
* @returns Int16Array of PCM audio
|
|
137
|
+
*/
|
|
138
|
+
export function bytesToPcm16(bytes: Uint8Array): Int16Array {
|
|
139
|
+
if (bytes.length % 2 !== 0) {
|
|
140
|
+
// Trim the last byte to avoid RangeError
|
|
141
|
+
bytes = bytes.slice(0, bytes.length - 1);
|
|
142
|
+
}
|
|
143
|
+
return new Int16Array(bytes.buffer, bytes.byteOffset, bytes.length / 2);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Normalize audio amplitude to prevent clipping
|
|
148
|
+
* @param data Float32Array of audio
|
|
149
|
+
* @param targetPeak Peak level to normalize to (0-1)
|
|
150
|
+
* @returns Normalized Float32Array
|
|
151
|
+
*/
|
|
152
|
+
export function normalizeAudio(data: Float32Array, targetPeak: number = 0.95): Float32Array {
|
|
153
|
+
let maxAbs = 0;
|
|
154
|
+
for (let i = 0; i < data.length; i++) {
|
|
155
|
+
maxAbs = Math.max(maxAbs, Math.abs(data[i]));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (maxAbs === 0) return new Float32Array(data);
|
|
159
|
+
|
|
160
|
+
const scale = targetPeak / maxAbs;
|
|
161
|
+
const normalized = new Float32Array(data.length);
|
|
162
|
+
for (let i = 0; i < data.length; i++) {
|
|
163
|
+
normalized[i] = data[i] * scale;
|
|
164
|
+
}
|
|
165
|
+
return normalized;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Calculate RMS (Root Mean Square) amplitude
|
|
170
|
+
* @param data Float32Array or Uint8Array of audio
|
|
171
|
+
* @returns RMS value (0-1 for normalized float, 0-255 for byte data)
|
|
172
|
+
*/
|
|
173
|
+
export function calculateRMS(data: Float32Array | Uint8Array): number {
|
|
174
|
+
let sum = 0;
|
|
175
|
+
let length = data.length;
|
|
176
|
+
|
|
177
|
+
if (data instanceof Uint8Array) {
|
|
178
|
+
// For byte data (0-128 is silence, 128-255 is audio)
|
|
179
|
+
for (let i = 0; i < length; i++) {
|
|
180
|
+
const v = (data[i] - 128) / 128.0;
|
|
181
|
+
sum += v * v;
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
// For float data (-1 to 1)
|
|
185
|
+
for (let i = 0; i < length; i++) {
|
|
186
|
+
sum += data[i] * data[i];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return Math.sqrt(sum / length);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Create a resample function factory for streaming audio
|
|
195
|
+
* Useful for processing audio in chunks
|
|
196
|
+
*/
|
|
197
|
+
export class StreamResampler {
|
|
198
|
+
private inputBuffer: Float32Array = new Float32Array(0);
|
|
199
|
+
private inputRate: number;
|
|
200
|
+
private outputRate: number;
|
|
201
|
+
|
|
202
|
+
constructor(inputRate: number, outputRate: number) {
|
|
203
|
+
this.inputRate = inputRate;
|
|
204
|
+
this.outputRate = outputRate;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Process a chunk of audio and return resampled data
|
|
209
|
+
* @param inputChunk Float32Array chunk to process
|
|
210
|
+
* @param flush If true, output remaining buffered samples
|
|
211
|
+
* @returns Resampled Float32Array (may be empty if more data needed)
|
|
212
|
+
*/
|
|
213
|
+
process(inputChunk: Float32Array, flush: boolean = false): Float32Array {
|
|
214
|
+
// Concatenate new data with buffered data
|
|
215
|
+
const combined = new Float32Array(this.inputBuffer.length + inputChunk.length);
|
|
216
|
+
combined.set(this.inputBuffer);
|
|
217
|
+
combined.set(inputChunk, this.inputBuffer.length);
|
|
218
|
+
|
|
219
|
+
const ratio = this.inputRate / this.outputRate;
|
|
220
|
+
let outputLength = Math.floor(combined.length / ratio);
|
|
221
|
+
|
|
222
|
+
if (outputLength === 0 && !flush) {
|
|
223
|
+
this.inputBuffer = combined;
|
|
224
|
+
return new Float32Array(0);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// On flush, output everything including partial frames
|
|
228
|
+
if (flush && outputLength === 0 && combined.length > 0) {
|
|
229
|
+
outputLength = 1;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const output = new Float32Array(outputLength);
|
|
233
|
+
|
|
234
|
+
for (let i = 0; i < outputLength; i++) {
|
|
235
|
+
const pos = i * ratio;
|
|
236
|
+
const left = Math.floor(pos);
|
|
237
|
+
const right = Math.min(left + 1, combined.length - 1);
|
|
238
|
+
const weight = pos - left;
|
|
239
|
+
|
|
240
|
+
output[i] = combined[left] * (1 - weight) + combined[right] * weight;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Keep remaining samples in buffer
|
|
244
|
+
const consumed = Math.floor(outputLength * ratio);
|
|
245
|
+
this.inputBuffer = combined.slice(consumed);
|
|
246
|
+
|
|
247
|
+
return output;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
reset(): void {
|
|
251
|
+
this.inputBuffer = new Float32Array(0);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { AUDIO_CONFIG } from './types';
|
|
2
|
+
import { float32ToPcm16, pcm16ToFloat32, StreamResampler, calculateRMS } from './audio-utils';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Configuration for browser audio handling
|
|
6
|
+
*/
|
|
7
|
+
export interface BrowserAudioConfig {
|
|
8
|
+
inputSampleRate?: number;
|
|
9
|
+
outputSampleRate?: number;
|
|
10
|
+
autoGainControl?: boolean;
|
|
11
|
+
echoCancellation?: boolean;
|
|
12
|
+
noiseSuppression?: boolean;
|
|
13
|
+
onInputError?: (error: Error) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Analyser configuration for audio visualization
|
|
18
|
+
*/
|
|
19
|
+
export interface AnalyserConfig {
|
|
20
|
+
enabled?: boolean;
|
|
21
|
+
fftSize?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Browser-based audio manager for Web Audio API operations
|
|
26
|
+
* Handles microphone input, speaker output, and visualization
|
|
27
|
+
*/
|
|
28
|
+
export class BrowserAudioManager {
|
|
29
|
+
private audioContext: AudioContext | null = null;
|
|
30
|
+
private mediaStreamAudioSourceNode: MediaStreamAudioSourceNode | null = null;
|
|
31
|
+
private scriptProcessor: ScriptProcessorNode | null = null;
|
|
32
|
+
private analyserNode: AnalyserNode | null = null;
|
|
33
|
+
private mediaStream: MediaStream | null = null;
|
|
34
|
+
private resampler: StreamResampler | null = null;
|
|
35
|
+
|
|
36
|
+
// Playback scheduling
|
|
37
|
+
private nextPlaybackTime: number = 0;
|
|
38
|
+
private activeSources: AudioBufferSourceNode[] = [];
|
|
39
|
+
private playbackQueue: AudioBuffer[] = [];
|
|
40
|
+
|
|
41
|
+
// Configuration
|
|
42
|
+
private inputSampleRate: number;
|
|
43
|
+
private outputSampleRate: number;
|
|
44
|
+
private autoGainControl: boolean;
|
|
45
|
+
private echoCancellation: boolean;
|
|
46
|
+
private noiseSuppression: boolean;
|
|
47
|
+
|
|
48
|
+
// Callbacks
|
|
49
|
+
private onAudioInput?: (pcm16Data: Uint8Array) => void;
|
|
50
|
+
private onInputError?: (error: Error) => void;
|
|
51
|
+
|
|
52
|
+
// Audio processing state
|
|
53
|
+
private isMuted: boolean = false;
|
|
54
|
+
private isListening: boolean = false;
|
|
55
|
+
|
|
56
|
+
constructor(config: BrowserAudioConfig = {}) {
|
|
57
|
+
this.inputSampleRate = config.inputSampleRate ?? AUDIO_CONFIG.SAMPLE_RATE;
|
|
58
|
+
this.outputSampleRate = config.outputSampleRate ?? AUDIO_CONFIG.SPEAKER_SAMPLE_RATE;
|
|
59
|
+
this.autoGainControl = config.autoGainControl ?? true;
|
|
60
|
+
this.echoCancellation = config.echoCancellation ?? true;
|
|
61
|
+
this.noiseSuppression = config.noiseSuppression ?? true;
|
|
62
|
+
this.onInputError = config.onInputError;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Initialize the AudioContext and analyser
|
|
67
|
+
*/
|
|
68
|
+
async init(analyserConfig?: AnalyserConfig): Promise<void> {
|
|
69
|
+
if (this.audioContext) return; // Already initialized
|
|
70
|
+
|
|
71
|
+
const AudioContextClass =
|
|
72
|
+
(window as any).AudioContext || (window as any).webkitAudioContext;
|
|
73
|
+
if (!AudioContextClass) {
|
|
74
|
+
throw new Error('Web Audio API not supported in this browser');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.audioContext = new AudioContextClass();
|
|
78
|
+
|
|
79
|
+
// Ensure AudioContext is running (not suspended)
|
|
80
|
+
if (!this.audioContext) {
|
|
81
|
+
throw new Error('Failed to initialize AudioContext');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (this.audioContext.state === 'suspended') {
|
|
85
|
+
await this.audioContext.resume();
|
|
86
|
+
console.log('👂 AudioContext resumed');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Setup analyser for visualization if enabled
|
|
90
|
+
if (analyserConfig?.enabled !== false) {
|
|
91
|
+
this.analyserNode = this.audioContext.createAnalyser();
|
|
92
|
+
this.analyserNode.fftSize = analyserConfig?.fftSize ?? 256;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Start capturing audio from the microphone
|
|
98
|
+
*/
|
|
99
|
+
async startMicrophone(
|
|
100
|
+
onAudioInput: (pcm16Data: Uint8Array) => void
|
|
101
|
+
): Promise<void> {
|
|
102
|
+
if (!this.audioContext) {
|
|
103
|
+
await this.init();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
this.onAudioInput = onAudioInput;
|
|
108
|
+
this.isListening = true;
|
|
109
|
+
|
|
110
|
+
// Request microphone access with constraints
|
|
111
|
+
this.mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
112
|
+
audio: {
|
|
113
|
+
autoGainControl: this.autoGainControl,
|
|
114
|
+
echoCancellation: this.echoCancellation,
|
|
115
|
+
noiseSuppression: this.noiseSuppression,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Create source from microphone stream
|
|
120
|
+
this.mediaStreamAudioSourceNode =
|
|
121
|
+
this.audioContext!.createMediaStreamSource(this.mediaStream);
|
|
122
|
+
|
|
123
|
+
// Create script processor for PCM extraction
|
|
124
|
+
// Note: ScriptProcessorNode is deprecated but widely supported.
|
|
125
|
+
// AudioWorklet would be better but requires additional setup.
|
|
126
|
+
const bufferSize = 4096;
|
|
127
|
+
this.scriptProcessor = this.audioContext!.createScriptProcessor(
|
|
128
|
+
bufferSize,
|
|
129
|
+
1, // input channels
|
|
130
|
+
1 // output channels
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// Connect the audio graph
|
|
134
|
+
this.mediaStreamAudioSourceNode.connect(this.scriptProcessor);
|
|
135
|
+
this.scriptProcessor.connect(this.audioContext!.destination);
|
|
136
|
+
|
|
137
|
+
// Connect to analyser if available
|
|
138
|
+
if (this.analyserNode) {
|
|
139
|
+
this.mediaStreamAudioSourceNode.connect(this.analyserNode);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Initialize stateful resampler if sample rates differ
|
|
143
|
+
const hardwareRate = this.audioContext!.sampleRate;
|
|
144
|
+
if (hardwareRate !== this.inputSampleRate) {
|
|
145
|
+
this.resampler = new StreamResampler(hardwareRate, this.inputSampleRate);
|
|
146
|
+
} else {
|
|
147
|
+
this.resampler = null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Handle audio processing
|
|
151
|
+
this.scriptProcessor.onaudioprocess = (event: AudioProcessingEvent) => {
|
|
152
|
+
this._processAudioInput(event);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
console.log('🎤 Microphone started');
|
|
156
|
+
} catch (error) {
|
|
157
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
158
|
+
if (this.onInputError) this.onInputError(err);
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Internal method to process microphone audio data
|
|
165
|
+
*/
|
|
166
|
+
private _processAudioInput(event: AudioProcessingEvent): void {
|
|
167
|
+
if (!this.onAudioInput || !this.audioContext || !this.isListening) return;
|
|
168
|
+
if (this.isMuted) return;
|
|
169
|
+
|
|
170
|
+
const inputBuffer = event.inputBuffer;
|
|
171
|
+
const inputData = inputBuffer.getChannelData(0);
|
|
172
|
+
|
|
173
|
+
// Silence output to prevent feedback
|
|
174
|
+
const outputBuffer = event.outputBuffer;
|
|
175
|
+
for (let i = 0; i < outputBuffer.getChannelData(0).length; i++) {
|
|
176
|
+
outputBuffer.getChannelData(0)[i] = 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Resample from hardware rate to target rate if needed
|
|
180
|
+
let processedData: Float32Array = new Float32Array(inputData);
|
|
181
|
+
|
|
182
|
+
if (this.resampler) {
|
|
183
|
+
processedData = this.resampler.process(processedData);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (processedData.length === 0) return; // Need more data for resampler
|
|
187
|
+
|
|
188
|
+
// Convert Float32 to Int16 PCM
|
|
189
|
+
const int16Data = float32ToPcm16(processedData);
|
|
190
|
+
const uint8Data = new Uint8Array(
|
|
191
|
+
int16Data.buffer,
|
|
192
|
+
int16Data.byteOffset,
|
|
193
|
+
int16Data.byteLength
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
this.onAudioInput(uint8Data);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Stop capturing microphone input
|
|
201
|
+
*/
|
|
202
|
+
stopMicrophone(): void {
|
|
203
|
+
this.isListening = false;
|
|
204
|
+
|
|
205
|
+
if (this.mediaStream) {
|
|
206
|
+
this.mediaStream.getTracks().forEach((track) => track.stop());
|
|
207
|
+
this.mediaStream = null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (this.scriptProcessor) {
|
|
211
|
+
this.scriptProcessor.disconnect();
|
|
212
|
+
this.scriptProcessor = null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (this.mediaStreamAudioSourceNode) {
|
|
216
|
+
this.mediaStreamAudioSourceNode.disconnect();
|
|
217
|
+
this.mediaStreamAudioSourceNode = null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
console.log('🎤 Microphone stopped');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Play back audio received from the server
|
|
225
|
+
* @param pcm16Data Int16 PCM audio data at SPEAKER_SAMPLE_RATE
|
|
226
|
+
*/
|
|
227
|
+
playAudio(pcm16Data: Uint8Array): void {
|
|
228
|
+
if (!this.audioContext) {
|
|
229
|
+
console.warn('AudioContext not initialized');
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (pcm16Data.length % 2 !== 0) {
|
|
234
|
+
console.warn(`Discarding odd-length PCM buffer (${pcm16Data.length} bytes)`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Convert Int16 to Float32
|
|
239
|
+
const int16Array = new Int16Array(
|
|
240
|
+
pcm16Data.buffer,
|
|
241
|
+
pcm16Data.byteOffset,
|
|
242
|
+
pcm16Data.length / 2
|
|
243
|
+
);
|
|
244
|
+
const float32Data = pcm16ToFloat32(int16Array);
|
|
245
|
+
|
|
246
|
+
// Create audio buffer
|
|
247
|
+
const audioBuffer = this.audioContext.createBuffer(
|
|
248
|
+
1,
|
|
249
|
+
float32Data.length,
|
|
250
|
+
this.outputSampleRate
|
|
251
|
+
);
|
|
252
|
+
audioBuffer.getChannelData(0).set(float32Data);
|
|
253
|
+
|
|
254
|
+
// Schedule playback
|
|
255
|
+
this._schedulePlayback(audioBuffer);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Internal method to schedule and play audio with sample-accurate timing
|
|
260
|
+
*/
|
|
261
|
+
private _schedulePlayback(audioBuffer: AudioBuffer): void {
|
|
262
|
+
if (!this.audioContext) return;
|
|
263
|
+
|
|
264
|
+
const currentTime = this.audioContext.currentTime;
|
|
265
|
+
const duration = audioBuffer.length / this.outputSampleRate;
|
|
266
|
+
|
|
267
|
+
// Schedule playback to occur seamlessly after previous audio
|
|
268
|
+
const startTime = Math.max(
|
|
269
|
+
currentTime + 0.01, // Minimum 10ms delay
|
|
270
|
+
this.nextPlaybackTime
|
|
271
|
+
);
|
|
272
|
+
this.nextPlaybackTime = startTime + duration;
|
|
273
|
+
|
|
274
|
+
// Create and configure source node
|
|
275
|
+
const source = this.audioContext.createBufferSource();
|
|
276
|
+
source.buffer = audioBuffer;
|
|
277
|
+
source.connect(this.audioContext.destination);
|
|
278
|
+
|
|
279
|
+
if (this.analyserNode) {
|
|
280
|
+
source.connect(this.analyserNode);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
source.start(startTime);
|
|
284
|
+
this.activeSources.push(source);
|
|
285
|
+
|
|
286
|
+
// Clean up source reference when finished
|
|
287
|
+
source.onended = () => {
|
|
288
|
+
const index = this.activeSources.indexOf(source);
|
|
289
|
+
if (index > -1) {
|
|
290
|
+
this.activeSources.splice(index, 1);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Stop all currently playing audio and clear the queue
|
|
297
|
+
*/
|
|
298
|
+
stopPlayback(): void {
|
|
299
|
+
this.activeSources.forEach((source) => {
|
|
300
|
+
try {
|
|
301
|
+
source.stop();
|
|
302
|
+
} catch (e) {
|
|
303
|
+
// Already stopped or other error
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
this.activeSources = [];
|
|
307
|
+
this.playbackQueue = [];
|
|
308
|
+
this.nextPlaybackTime = this.audioContext?.currentTime ?? 0;
|
|
309
|
+
console.log('🔇 Playback stopped');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Toggle mute state
|
|
314
|
+
*/
|
|
315
|
+
setMuted(muted: boolean): void {
|
|
316
|
+
this.isMuted = muted;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Get current mute state
|
|
321
|
+
*/
|
|
322
|
+
isMicMuted(): boolean {
|
|
323
|
+
return this.isMuted;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Get current amplitude from analyser (for visualization)
|
|
328
|
+
* Returns value between 0 and 1
|
|
329
|
+
*/
|
|
330
|
+
getAmplitude(): number {
|
|
331
|
+
if (!this.analyserNode) return 0;
|
|
332
|
+
|
|
333
|
+
const dataArray = new Uint8Array(this.analyserNode.frequencyBinCount);
|
|
334
|
+
this.analyserNode.getByteTimeDomainData(dataArray);
|
|
335
|
+
|
|
336
|
+
const rms = calculateRMS(dataArray);
|
|
337
|
+
return Math.min(rms * 10, 1); // Boost for visualization
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Get frequency data from analyser for visualization
|
|
342
|
+
*/
|
|
343
|
+
getFrequencyData(): Uint8Array {
|
|
344
|
+
if (!this.analyserNode) {
|
|
345
|
+
return new Uint8Array(0);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const dataArray = new Uint8Array(this.analyserNode.frequencyBinCount);
|
|
349
|
+
this.analyserNode.getByteFrequencyData(dataArray);
|
|
350
|
+
return dataArray;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Get time-domain data from analyser for waveform visualization
|
|
355
|
+
*/
|
|
356
|
+
getWaveformData(): Uint8Array {
|
|
357
|
+
if (!this.analyserNode) {
|
|
358
|
+
return new Uint8Array(0);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const dataArray = new Uint8Array(this.analyserNode.frequencyBinCount);
|
|
362
|
+
this.analyserNode.getByteTimeDomainData(dataArray);
|
|
363
|
+
return dataArray;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Cleanup and close AudioContext
|
|
368
|
+
*/
|
|
369
|
+
cleanup(): void {
|
|
370
|
+
this.stopMicrophone();
|
|
371
|
+
this.stopPlayback();
|
|
372
|
+
|
|
373
|
+
if (this.analyserNode) {
|
|
374
|
+
this.analyserNode.disconnect();
|
|
375
|
+
this.analyserNode = null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Don't close AudioContext - it can be expensive to recreate
|
|
379
|
+
// Just leave it suspended if no longer needed
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Get current audio context state
|
|
384
|
+
*/
|
|
385
|
+
getState(): 'running' | 'suspended' | 'closed' | 'interrupted' | null {
|
|
386
|
+
return this.audioContext?.state ?? null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Check if microphone is currently listening
|
|
391
|
+
*/
|
|
392
|
+
isRecording(): boolean {
|
|
393
|
+
return this.isListening;
|
|
394
|
+
}
|
|
395
|
+
}
|