@lokutor/sdk 1.1.17 → 1.1.20

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/package.json CHANGED
@@ -1,12 +1,20 @@
1
1
  {
2
2
  "name": "@lokutor/sdk",
3
- "version": "1.1.17",
3
+ "version": "1.1.20",
4
4
  "description": "JavaScript/TypeScript SDK for Lokutor Real-time Voice AI",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
7
7
  "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
8
15
  "files": [
9
- "dist"
16
+ "dist",
17
+ "src"
10
18
  ],
11
19
  "scripts": {
12
20
  "build": "tsup src/index.ts --format cjs,esm --dts",
@@ -32,6 +40,7 @@
32
40
  "@types/node": "^20.10.0",
33
41
  "tsup": "^8.0.1",
34
42
  "typescript": "^5.3.2",
35
- "vitest": "^1.0.1"
43
+ "vitest": "^1.0.1",
44
+ "wavefile": "^11.0.0"
36
45
  }
37
- }
46
+ }
@@ -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
+ }