-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathaudio-loader.js
More file actions
325 lines (293 loc) · 11 KB
/
audio-loader.js
File metadata and controls
325 lines (293 loc) · 11 KB
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
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
/**
* Enhanced Audio Loader with optimizations for GitHub Pages
* - Prioritizes mp3 over flac for better compatibility and faster loading
* - Uses jsDelivr CDN when available
* - Loads audio on-demand when user clicks play (lazy loading)
* - Uses Web Workers for audio decoding when available
*/
// Pre-initialize the audio context for faster decoding
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
// Create a decoder worker
let decoderWorker = null;
try {
decoderWorker = new Worker('decoder-worker.js');
} catch (error) {
console.warn('Audio decoder worker not available:', error);
}
class AudioLoader {
constructor(options = {}) {
// Default options
this.options = {
// GitHub username and repository name
username: 'ace-step',
repo: 'ace-step.github.io',
// GitHub release tag (if using releases)
releaseTag: 'latest',
// Whether to use jsDelivr CDN
useJsDelivr: true,
// Format preference order (mp3 first for better compatibility, then flac)
formatPreference: ['mp3', 'flac'],
// Base path for local files
localBasePath: '',
// Whether to use progressive loading
useProgressiveLoading: true,
// Whether to use web worker for decoding
useWorkerDecoding: true,
...options
};
// Cache for audio file availability and decoded data
this.cache = new Map();
this.decodedCache = new Map();
// Initialize decoder worker message handling
if (decoderWorker && this.options.useWorkerDecoding) {
decoderWorker.onmessage = async (e) => {
const { id, decodedData, error, needsMainThreadDecode, audioData } = e.data;
// If we have decoded data, use it
if (id && decodedData) {
this.decodedCache.set(id, decodedData);
// Notify any pending callbacks
if (this.pendingDecodes.has(id)) {
const callbacks = this.pendingDecodes.get(id);
callbacks.forEach(callback => callback(decodedData));
this.pendingDecodes.delete(id);
}
}
// If worker couldn't decode because OfflineAudioContext isn't available, decode in main thread
else if (id && needsMainThreadDecode && audioData) {
try {
console.log('Falling back to main thread decoding because OfflineAudioContext is not available in worker');
const decodedData = await audioContext.decodeAudioData(audioData);
this.decodedCache.set(id, decodedData);
// Notify any pending callbacks
if (this.pendingDecodes.has(id)) {
const callbacks = this.pendingDecodes.get(id);
callbacks.forEach(callback => callback(decodedData));
this.pendingDecodes.delete(id);
}
} catch (decodeError) {
console.error('Error decoding audio in main thread fallback:', decodeError);
// Notify callbacks of error
if (this.pendingDecodes.has(id)) {
const callbacks = this.pendingDecodes.get(id);
callbacks.forEach(callback => callback(null, decodeError));
this.pendingDecodes.delete(id);
}
}
}
// Handle other errors
else if (error) {
console.error('Worker reported error:', error);
// Notify callbacks of error
if (this.pendingDecodes.has(id)) {
const callbacks = this.pendingDecodes.get(id);
callbacks.forEach(callback => callback(null, new Error(error)));
this.pendingDecodes.delete(id);
}
}
};
// Track pending decode operations
this.pendingDecodes = new Map();
}
// Pre-warm the audio context
this._preWarmAudioContext();
}
/**
* Pre-warm the audio context to reduce initial decode latency
* @private
*/
async _preWarmAudioContext() {
try {
// Create a small silent buffer
const buffer = audioContext.createBuffer(2, 44100, 44100);
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(audioContext.destination);
source.start(0);
source.stop(0.001); // Stop after a very short time
console.log('Audio context pre-warmed');
} catch (error) {
console.warn('Failed to pre-warm audio context:', error);
}
}
/**
* Get the URL for an audio file
* @param {string} directory - Directory containing the audio file
* @param {string} fileName - Name of the audio file without extension
* @returns {Promise<string>} - URL to the audio file
*/
async getAudioUrl(directory, fileName) {
const cacheKey = `${directory}/${fileName}`;
// Check cache first
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey);
}
// Try each format in order of preference
for (const format of this.options.formatPreference) {
// Try to load from jsDelivr CDN first if enabled
if (this.options.useJsDelivr) {
const cdnUrl = this.getJsDelivrUrl(directory, fileName, format);
if (await this.checkFileExists(cdnUrl)) {
this.cache.set(cacheKey, cdnUrl);
console.log(`Using CDN for ${fileName}.${format}`);
return cdnUrl;
}
}
// Fall back to local files
const localPath = this.getLocalPath(directory, fileName, format);
if (await this.checkFileExists(localPath)) {
this.cache.set(cacheKey, localPath);
return localPath;
}
}
// If all else fails, return the original flac path
const fallbackPath = `flac/samples/${directory}/${fileName}.flac`;
this.cache.set(cacheKey, fallbackPath);
return fallbackPath;
}
/**
* Get the jsDelivr CDN URL for a file
* @param {string} directory - Directory containing the file
* @param {string} fileName - Name of the file without extension
* @param {string} format - File format (opus, mp3 or flac)
* @returns {string} - jsDelivr CDN URL
*/
getJsDelivrUrl(directory, fileName, format) {
if (format === 'opus') {
return `https://cdn.jsdelivr.net/gh/${this.options.username}/${this.options.repo}/opus/samples/${directory}/${fileName}.opus`;
} else if (format === 'mp3') {
return `https://cdn.jsdelivr.net/gh/${this.options.username}/${this.options.repo}/mp3/samples/${directory}/${fileName}.mp3`;
} else {
return `https://cdn.jsdelivr.net/gh/${this.options.username}/${this.options.repo}/flac/samples/${directory}/${fileName}.flac`;
}
}
/**
* Get the local path for a file
* @param {string} directory - Directory containing the file
* @param {string} fileName - Name of the file without extension
* @param {string} format - File format (opus, mp3 or flac)
* @returns {string} - Local file path
*/
getLocalPath(directory, fileName, format) {
if (format === 'opus') {
return `${this.options.localBasePath}opus/samples/${directory}/${fileName}.opus`;
} else if (format === 'mp3') {
return `${this.options.localBasePath}mp3/samples/${directory}/${fileName}.mp3`;
} else {
return `${this.options.localBasePath}flac/samples/${directory}/${fileName}.flac`;
}
}
/**
* Check if a file exists by making a HEAD request
* @param {string} url - URL to check
* @returns {Promise<boolean>} - Whether the file exists
*/
async checkFileExists(url) {
try {
const response = await fetch(url, { method: 'HEAD' });
return response.ok;
} catch (error) {
console.warn(`Error checking file existence for ${url}:`, error);
return false;
}
}
/**
* Pre-decode an audio file to reduce playback latency
* @param {string} url - URL of the audio file to decode
* @param {string} id - Unique identifier for the audio file
* @returns {Promise<AudioBuffer>} - Decoded audio data
*/
async preDecodeAudio(url, id) {
// Check if already decoded
if (this.decodedCache.has(id)) {
return this.decodedCache.get(id);
}
try {
// Fetch the audio data
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
// Use worker for decoding if available
if (decoderWorker && this.options.useWorkerDecoding) {
return new Promise((resolve, reject) => {
// Add to pending decodes
if (!this.pendingDecodes.has(id)) {
this.pendingDecodes.set(id, []);
}
// Add callback that handles both success and error cases
this.pendingDecodes.get(id).push((data, error) => {
if (error) {
reject(error);
} else {
resolve(data);
}
});
// Send to worker for decoding
decoderWorker.postMessage({
id,
audioData: arrayBuffer
}, [arrayBuffer]);
});
} else {
// Decode in main thread if worker not available
const decodedData = await audioContext.decodeAudioData(arrayBuffer);
this.decodedCache.set(id, decodedData);
return decodedData;
}
} catch (error) {
console.error(`Error pre-decoding audio ${url}:`, error);
return null;
}
}
/**
* Create an optimized audio element with progressive loading
* @param {string} url - URL of the audio file
* @param {string} id - Unique identifier for the audio file
* @returns {HTMLAudioElement} - Configured audio element
*/
createOptimizedAudio(url, id) {
const audio = new Audio();
// Configure for progressive loading
if (this.options.useProgressiveLoading) {
audio.preload = 'none';
audio.dataset.src = url;
audio.dataset.id = id;
// Set up progressive loading on play
audio.addEventListener('play', () => {
if (!audio.dataset.loadStarted) {
audio.src = audio.dataset.src;
audio.load();
audio.dataset.loadStarted = true;
}
});
} else {
// Standard loading
audio.src = url;
audio.preload = 'auto';
}
return audio;
}
/**
* Preload audio files for a list of samples
* @param {Array} samples - List of samples to preload
* @param {Function} progressCallback - Callback for progress updates
*/
async preloadAudio(samples, progressCallback = null) {
let loaded = 0;
const total = samples.length;
for (const sample of samples) {
const url = await this.getAudioUrl(sample.directory, sample.fileName);
// Pre-decode the audio if we're using that optimization
if (this.options.useWorkerDecoding || audioContext.state === 'running') {
await this.preDecodeAudio(url, sample.id);
} else {
// Just fetch the headers to cache the URL, don't download the whole file
await fetch(url, { method: 'HEAD' });
}
loaded++;
if (progressCallback) {
progressCallback(loaded, total);
}
}
}
}
// Export the AudioLoader class
window.AudioLoader = AudioLoader;