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