@idealyst/audio 1.2.48

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.
@@ -0,0 +1,517 @@
1
+ /**
2
+ * Native Player
3
+ *
4
+ * Uses react-native-audio-api for playback on React Native.
5
+ */
6
+
7
+ import {
8
+ AudioContext as RNAudioContext,
9
+ AudioBuffer as RNAudioBuffer,
10
+ AudioBufferSourceNode as RNAudioBufferSourceNode,
11
+ GainNode as RNGainNode,
12
+ } from 'react-native-audio-api';
13
+ import type {
14
+ IPlayer,
15
+ PlayerStatus,
16
+ PlayerState,
17
+ AudioConfig,
18
+ PlayerStateCallback,
19
+ PlayerPositionCallback,
20
+ PlayerBufferCallback,
21
+ PlayerEndedCallback,
22
+ IAudioContext,
23
+ } from '../types';
24
+ import {
25
+ DEFAULT_PLAYER_STATUS,
26
+ DEFAULT_POSITION_UPDATE_INTERVAL,
27
+ } from '../constants';
28
+ import {
29
+ createAudioError,
30
+ pcmToFloat32,
31
+ resampleLinear,
32
+ clamp,
33
+ samplesToDuration,
34
+ } from '../utils';
35
+
36
+ export class NativePlayer implements IPlayer {
37
+ private audioContext: IAudioContext;
38
+ private gainNode: RNGainNode | null = null;
39
+ private sourceNode: RNAudioBufferSourceNode | null = null;
40
+
41
+ private _status: PlayerStatus = { ...DEFAULT_PLAYER_STATUS };
42
+ private pcmConfig: AudioConfig | null = null;
43
+ private audioBuffer: RNAudioBuffer | null = null;
44
+ private startTime: number = 0;
45
+ private pausePosition: number = 0;
46
+
47
+ // PCM streaming
48
+ private pcmBuffer: Float32Array[] = [];
49
+ private isStreaming: boolean = false;
50
+ private streamScheduleInterval: ReturnType<typeof setInterval> | null = null;
51
+ private nextScheduleTime: number = 0;
52
+
53
+ // Position tracking
54
+ private positionIntervalId: ReturnType<typeof setInterval> | null = null;
55
+
56
+ // Callbacks
57
+ private stateCallbacks: Set<PlayerStateCallback> = new Set();
58
+ private positionCallbacks: Map<PlayerPositionCallback, number> = new Map();
59
+ private bufferCallbacks: Set<PlayerBufferCallback> = new Set();
60
+ private endedCallbacks: Set<PlayerEndedCallback> = new Set();
61
+
62
+ constructor(audioContext: IAudioContext) {
63
+ this.audioContext = audioContext;
64
+ }
65
+
66
+ get status(): PlayerStatus {
67
+ return { ...this._status };
68
+ }
69
+
70
+ async loadFile(uri: string): Promise<void> {
71
+ this.cleanup();
72
+ this.updateState('loading');
73
+
74
+ try {
75
+ if (!this.audioContext.isInitialized) {
76
+ await this.audioContext.initialize();
77
+ }
78
+
79
+ const ctx = this.audioContext.getContext() as RNAudioContext;
80
+ if (!ctx) {
81
+ throw createAudioError('INITIALIZATION_FAILED', 'AudioContext not available');
82
+ }
83
+
84
+ await this.ensureGainNode(ctx);
85
+
86
+ // Fetch the audio file
87
+ const response = await fetch(uri);
88
+ if (!response.ok) {
89
+ throw createAudioError('SOURCE_NOT_FOUND', `Failed to fetch audio: ${response.status}`);
90
+ }
91
+
92
+ const arrayBuffer = await response.arrayBuffer();
93
+ this.audioBuffer = await ctx.decodeAudioDataSource(arrayBuffer);
94
+
95
+ this._status.duration = this.audioBuffer.duration * 1000;
96
+ this.updateState('ready');
97
+ } catch (error: any) {
98
+ this.handleError(error);
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ unload(): void {
104
+ this.cleanup();
105
+ this._status = { ...DEFAULT_PLAYER_STATUS };
106
+ this.pcmConfig = null;
107
+ this.audioBuffer = null;
108
+ this.pcmBuffer = [];
109
+ this.notifyStateChange();
110
+ }
111
+
112
+ async loadPCMStream(config: AudioConfig): Promise<void> {
113
+ this.cleanup();
114
+ this.updateState('loading');
115
+
116
+ try {
117
+ if (!this.audioContext.isInitialized) {
118
+ await this.audioContext.initialize();
119
+ }
120
+
121
+ const ctx = this.audioContext.getContext() as RNAudioContext;
122
+ if (!ctx) {
123
+ throw createAudioError('INITIALIZATION_FAILED', 'AudioContext not available');
124
+ }
125
+
126
+ await this.ensureGainNode(ctx);
127
+
128
+ this.pcmConfig = { ...config };
129
+ this._status.duration = 0;
130
+ this.pcmBuffer = [];
131
+ this.isStreaming = false;
132
+ this.updateState('ready');
133
+ } catch (error: any) {
134
+ this.handleError(error);
135
+ throw error;
136
+ }
137
+ }
138
+
139
+ feedPCMData(data: ArrayBuffer | Int16Array): void {
140
+ if (!this.pcmConfig) {
141
+ console.warn('Cannot feed PCM data: stream not loaded');
142
+ return;
143
+ }
144
+
145
+ const ctx = this.audioContext.getContext() as RNAudioContext;
146
+ if (!ctx) return;
147
+
148
+ // Convert to Float32
149
+ let float32Samples: Float32Array;
150
+ if (data instanceof Int16Array) {
151
+ float32Samples = pcmToFloat32(data, 16);
152
+ } else {
153
+ float32Samples = pcmToFloat32(data, this.pcmConfig.bitDepth);
154
+ }
155
+
156
+ // Resample if needed
157
+ if (this.pcmConfig.sampleRate !== ctx.sampleRate) {
158
+ float32Samples = resampleLinear(
159
+ float32Samples,
160
+ this.pcmConfig.sampleRate,
161
+ ctx.sampleRate
162
+ );
163
+ }
164
+
165
+ // Add to buffer
166
+ this.pcmBuffer.push(float32Samples);
167
+
168
+ // Update buffered status
169
+ const totalSamples = this.pcmBuffer.reduce((sum, arr) => sum + arr.length, 0);
170
+ const bufferedMs = samplesToDuration(totalSamples, ctx.sampleRate);
171
+ this._status.buffered = bufferedMs;
172
+ this.notifyBufferChange(bufferedMs);
173
+
174
+ // If playing, schedule the new samples
175
+ if (this.isStreaming && this._status.state === 'playing') {
176
+ this.scheduleNextBuffer();
177
+ }
178
+ }
179
+
180
+ async flush(): Promise<void> {
181
+ if (this.isStreaming && this._status.state === 'playing') {
182
+ return new Promise((resolve) => {
183
+ const checkBuffer = () => {
184
+ const totalSamples = this.pcmBuffer.reduce((sum, arr) => sum + arr.length, 0);
185
+ if (totalSamples === 0 || this._status.state !== 'playing') {
186
+ resolve();
187
+ } else {
188
+ setTimeout(checkBuffer, 50);
189
+ }
190
+ };
191
+ checkBuffer();
192
+ });
193
+ }
194
+ }
195
+
196
+ async play(): Promise<void> {
197
+ const ctx = this.audioContext.getContext() as RNAudioContext;
198
+ if (!ctx) {
199
+ throw createAudioError('INVALID_STATE', 'No audio source loaded');
200
+ }
201
+
202
+ if (this.pcmConfig) {
203
+ // PCM streaming playback
204
+ this.isStreaming = true;
205
+ this.nextScheduleTime = ctx.currentTime;
206
+ this.startTime = ctx.currentTime;
207
+ this.startStreamScheduler();
208
+ this.startPositionTracking();
209
+ this.updateState('playing');
210
+ } else if (this.audioBuffer) {
211
+ // File playback
212
+ this.stopSourceNode();
213
+
214
+ this.sourceNode = ctx.createBufferSource();
215
+ this.sourceNode.buffer = this.audioBuffer;
216
+ this.sourceNode.connect(this.gainNode!);
217
+
218
+ // Note: react-native-audio-api may not support onended callback
219
+ // We'll rely on position tracking for end detection
220
+
221
+ const offset = this.pausePosition / 1000;
222
+ this.sourceNode.start(0, offset);
223
+ this.startTime = ctx.currentTime - offset;
224
+ this.startPositionTracking();
225
+ this.updateState('playing');
226
+ }
227
+ }
228
+
229
+ pause(): void {
230
+ if (this._status.state !== 'playing') return;
231
+
232
+ const ctx = this.audioContext.getContext() as RNAudioContext;
233
+
234
+ if (this.pcmConfig) {
235
+ this.isStreaming = false;
236
+ this.stopStreamScheduler();
237
+ } else if (this.sourceNode && ctx) {
238
+ this.pausePosition = (ctx.currentTime - this.startTime) * 1000;
239
+ this.stopSourceNode();
240
+ }
241
+
242
+ this.stopPositionTracking();
243
+ this.updateState('paused');
244
+ }
245
+
246
+ stop(): void {
247
+ if (this._status.state === 'idle') return;
248
+
249
+ if (this.pcmConfig) {
250
+ this.isStreaming = false;
251
+ this.stopStreamScheduler();
252
+ this.pcmBuffer = [];
253
+ } else if (this.sourceNode) {
254
+ this.stopSourceNode();
255
+ }
256
+
257
+ this.stopPositionTracking();
258
+ this._status.position = 0;
259
+ this.pausePosition = 0;
260
+ this._status.buffered = 0;
261
+ this.updateState('stopped');
262
+ }
263
+
264
+ async seek(positionMs: number): Promise<void> {
265
+ if (this.pcmConfig) {
266
+ throw createAudioError('INVALID_STATE', 'Cannot seek in PCM stream');
267
+ }
268
+
269
+ if (!this.audioBuffer) {
270
+ throw createAudioError('INVALID_STATE', 'No audio loaded');
271
+ }
272
+
273
+ const wasPlaying = this._status.state === 'playing';
274
+ const clampedPosition = clamp(positionMs, 0, this._status.duration);
275
+
276
+ if (wasPlaying) {
277
+ this.stopSourceNode();
278
+ }
279
+
280
+ this.pausePosition = clampedPosition;
281
+ this._status.position = clampedPosition;
282
+ this.notifyStateChange();
283
+
284
+ if (wasPlaying) {
285
+ await this.play();
286
+ }
287
+ }
288
+
289
+ setVolume(volume: number): void {
290
+ const clampedVolume = clamp(volume, 0, 1);
291
+ this._status.volume = clampedVolume;
292
+
293
+ const ctx = this.audioContext.getContext() as RNAudioContext;
294
+ if (this.gainNode && ctx) {
295
+ this.gainNode.gain.setValueAtTime(
296
+ this._status.muted ? 0 : clampedVolume,
297
+ ctx.currentTime
298
+ );
299
+ }
300
+
301
+ this.notifyStateChange();
302
+ }
303
+
304
+ setMuted(muted: boolean): void {
305
+ this._status.muted = muted;
306
+
307
+ const ctx = this.audioContext.getContext() as RNAudioContext;
308
+ if (this.gainNode && ctx) {
309
+ this.gainNode.gain.setValueAtTime(
310
+ muted ? 0 : this._status.volume,
311
+ ctx.currentTime
312
+ );
313
+ }
314
+
315
+ this.notifyStateChange();
316
+ }
317
+
318
+ onStateChange(callback: PlayerStateCallback): () => void {
319
+ this.stateCallbacks.add(callback);
320
+ return () => this.stateCallbacks.delete(callback);
321
+ }
322
+
323
+ onPosition(callback: PlayerPositionCallback, intervalMs = DEFAULT_POSITION_UPDATE_INTERVAL): () => void {
324
+ this.positionCallbacks.set(callback, intervalMs);
325
+ return () => this.positionCallbacks.delete(callback);
326
+ }
327
+
328
+ onBufferChange(callback: PlayerBufferCallback): () => void {
329
+ this.bufferCallbacks.add(callback);
330
+ return () => this.bufferCallbacks.delete(callback);
331
+ }
332
+
333
+ onEnded(callback: PlayerEndedCallback): () => void {
334
+ this.endedCallbacks.add(callback);
335
+ return () => this.endedCallbacks.delete(callback);
336
+ }
337
+
338
+ dispose(): void {
339
+ this.cleanup();
340
+ this.stateCallbacks.clear();
341
+ this.positionCallbacks.clear();
342
+ this.bufferCallbacks.clear();
343
+ this.endedCallbacks.clear();
344
+ }
345
+
346
+ // Private methods
347
+
348
+ private async ensureGainNode(ctx: RNAudioContext): Promise<void> {
349
+ if (!this.gainNode) {
350
+ this.gainNode = ctx.createGain();
351
+ this.gainNode.gain.setValueAtTime(this._status.volume, ctx.currentTime);
352
+ this.gainNode.connect(ctx.destination);
353
+ }
354
+ }
355
+
356
+ private scheduleNextBuffer(): void {
357
+ const ctx = this.audioContext.getContext() as RNAudioContext;
358
+ if (!ctx || !this.gainNode || this.pcmBuffer.length === 0) return;
359
+
360
+ const chunk = this.pcmBuffer.shift();
361
+ if (!chunk) return;
362
+
363
+ const audioBuffer = ctx.createBuffer(1, chunk.length, ctx.sampleRate);
364
+ audioBuffer.copyToChannel(chunk, 0);
365
+
366
+ const source = ctx.createBufferSource();
367
+ source.buffer = audioBuffer;
368
+ source.connect(this.gainNode);
369
+
370
+ const scheduleTime = Math.max(this.nextScheduleTime, ctx.currentTime);
371
+ source.start(scheduleTime);
372
+
373
+ this.nextScheduleTime = scheduleTime + audioBuffer.duration;
374
+
375
+ // Update buffered status
376
+ const totalSamples = this.pcmBuffer.reduce((sum, arr) => sum + arr.length, 0);
377
+ const bufferedMs = samplesToDuration(totalSamples, ctx.sampleRate);
378
+ this._status.buffered = bufferedMs;
379
+ this.notifyBufferChange(bufferedMs);
380
+ }
381
+
382
+ private startStreamScheduler(): void {
383
+ this.stopStreamScheduler();
384
+
385
+ this.streamScheduleInterval = setInterval(() => {
386
+ if (this.isStreaming && this.pcmBuffer.length > 0) {
387
+ this.scheduleNextBuffer();
388
+ }
389
+ }, 50);
390
+ }
391
+
392
+ private stopStreamScheduler(): void {
393
+ if (this.streamScheduleInterval) {
394
+ clearInterval(this.streamScheduleInterval);
395
+ this.streamScheduleInterval = null;
396
+ }
397
+ }
398
+
399
+ private stopSourceNode(): void {
400
+ if (this.sourceNode) {
401
+ try {
402
+ this.sourceNode.stop();
403
+ this.sourceNode.disconnect();
404
+ } catch {
405
+ // Ignore errors
406
+ }
407
+ this.sourceNode = null;
408
+ }
409
+ }
410
+
411
+ private cleanup(): void {
412
+ this.stopPositionTracking();
413
+ this.stopStreamScheduler();
414
+ this.stopSourceNode();
415
+ this.isStreaming = false;
416
+ }
417
+
418
+ private updateState(state: PlayerState): void {
419
+ this._status.state = state;
420
+ this._status.isPlaying = state === 'playing';
421
+ this._status.isPaused = state === 'paused';
422
+ this.notifyStateChange();
423
+ }
424
+
425
+ private notifyStateChange(): void {
426
+ const status = this.status;
427
+ this.stateCallbacks.forEach((callback) => {
428
+ try {
429
+ callback(status);
430
+ } catch (e) {
431
+ console.error('Error in state callback:', e);
432
+ }
433
+ });
434
+ }
435
+
436
+ private notifyBufferChange(buffered: number): void {
437
+ this.bufferCallbacks.forEach((callback) => {
438
+ try {
439
+ callback(buffered);
440
+ } catch (e) {
441
+ console.error('Error in buffer callback:', e);
442
+ }
443
+ });
444
+ }
445
+
446
+ private notifyEnded(): void {
447
+ this.endedCallbacks.forEach((callback) => {
448
+ try {
449
+ callback();
450
+ } catch (e) {
451
+ console.error('Error in ended callback:', e);
452
+ }
453
+ });
454
+ }
455
+
456
+ private startPositionTracking(): void {
457
+ this.stopPositionTracking();
458
+
459
+ let minInterval = DEFAULT_POSITION_UPDATE_INTERVAL;
460
+ this.positionCallbacks.forEach((interval) => {
461
+ if (interval < minInterval) minInterval = interval;
462
+ });
463
+
464
+ this.positionIntervalId = setInterval(() => {
465
+ const ctx = this.audioContext.getContext() as RNAudioContext;
466
+ if (this._status.state !== 'playing' || !ctx) return;
467
+
468
+ const position = (ctx.currentTime - this.startTime) * 1000;
469
+ this._status.position = position;
470
+
471
+ // Check if file playback ended
472
+ if (
473
+ !this.pcmConfig &&
474
+ this._status.duration > 0 &&
475
+ position >= this._status.duration
476
+ ) {
477
+ this.updateState('stopped');
478
+ this._status.position = 0;
479
+ this.pausePosition = 0;
480
+ this.stopPositionTracking();
481
+ this.notifyEnded();
482
+ return;
483
+ }
484
+
485
+ this.positionCallbacks.forEach((_, callback) => {
486
+ try {
487
+ callback(position);
488
+ } catch (e) {
489
+ console.error('Error in position callback:', e);
490
+ }
491
+ });
492
+ }, minInterval);
493
+ }
494
+
495
+ private stopPositionTracking(): void {
496
+ if (this.positionIntervalId) {
497
+ clearInterval(this.positionIntervalId);
498
+ this.positionIntervalId = null;
499
+ }
500
+ }
501
+
502
+ private handleError(error: any): void {
503
+ const audioError = createAudioError(
504
+ error.code || 'UNKNOWN',
505
+ error.message || String(error),
506
+ error instanceof Error ? error : undefined
507
+ );
508
+
509
+ this._status.state = 'error';
510
+ this._status.error = audioError;
511
+ this.notifyStateChange();
512
+ }
513
+ }
514
+
515
+ export function createPlayer(audioContext: IAudioContext): IPlayer {
516
+ return new NativePlayer(audioContext);
517
+ }