Skip to content

Commit

Permalink
Revamp CD-DA caching for iOS
Browse files Browse the repository at this point in the history
To address the long-standing issue of iOS Safari that it loses file
access in 1-2 minutes (https://bugs.webkit.org/show_bug.cgi?id=203806),
this introduces MP3Cache which encodes audio data to MP3 using lame
and store into indexedDB.

lame.{js,wasm} were built from kichikuou/lame-wasm@070d09a.
  • Loading branch information
kichikuou committed Aug 22, 2020
1 parent 5d6bd5a commit 4ca526d
Show file tree
Hide file tree
Showing 9 changed files with 396 additions and 43 deletions.
106 changes: 85 additions & 21 deletions docs/shell/cddacache.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,24 +31,28 @@ export class BasicCDDACache {
export class IOSCDDACache {
constructor(imageReader) {
this.imageReader = imageReader;
this.cache = [];
document.addEventListener('visibilitychange', this.onVisibilityChange.bind(this));
this.mp3Urls = [];
if (imageReader.cddaCacheKey)
this.mp3Cache = new MP3Cache(imageReader.cddaCacheKey);
}
async getCDDA(track) {
for (let entry of this.cache) {
if (entry.track === track) {
entry.time = performance.now();
return entry.url;
if (this.mp3Urls[track])
return this.mp3Urls[track];
if (this.mp3Cache) {
const mp3 = await this.mp3Cache.getTrack(track);
if (mp3) {
const url = URL.createObjectURL(mp3);
this.mp3Urls[track] = url;
return url;
}
}
this.shrink(3);
let blob = await this.imageReader.extractTrack(track);
try {
let buf = await readFileAsArrayBuffer(blob);
blob = new Blob([buf], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
this.cache.unshift({ track, url, time: performance.now() });
return url;
if (this.mp3Cache) {
let buf = await readFileAsArrayBuffer(blob);
this.mp3Cache.convertAndStore(track, buf);
}
return URL.createObjectURL(blob);
}
catch (e) {
if (e.constructor.name === 'FileError' && e.code === 1)
Expand All @@ -70,15 +74,75 @@ export class IOSCDDACache {
});
}
}
shrink(size) {
if (this.cache.length <= size)
return;
this.cache.sort((a, b) => b.time - a.time);
while (this.cache.length > size)
URL.revokeObjectURL(this.cache.pop().url);
}
const DB_NAME = 'cdda';
const STORE_NAME = 'tracks';
// A helper class of IOSCDDACache that encodes audio data to MP3 and store into IndexedDB.
class MP3Cache {
constructor(key) {
this.worker = new Worker('worker/cddacacheworker.js');
this.worker.addEventListener('message', (msg) => this.handleWorkerMessage(msg.data));
this.dbp = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME);
req.onsuccess = () => resolve(req.result);
req.onerror = () => {
gaException({ type: 'IDBOpen', err: req.error });
reject(req.error);
};
});
this.dbp.then(() => this.init(key));
}
convertAndStore(track, data) {
this.worker.postMessage({ track, data }, [data]);
}
onVisibilityChange() {
if (document.hidden)
this.shrink(1);
async getTrack(track) {
try {
return await this.get(track);
}
catch (err) {
return null;
}
}
handleWorkerMessage(msg) {
const blob = new Blob(msg.data, { type: 'audio/mp3' });
this.put(msg.track, blob);
}
async init(key) {
const oldKey = await this.get('key');
if (key != oldKey) {
if (oldKey)
ga('send', 'event', 'MP3Cache', 'purged');
await this.clear();
await this.put('key', key);
}
}
async get(key) {
let req;
await this.withStore('readonly', (s) => { req = s.get(key); });
return req.result;
}
put(key, val) {
return this.withStore('readwrite', (s) => s.put(val, key));
}
clear() {
return this.withStore('readwrite', (s) => s.clear());
}
async withStore(mode, callback) {
const db = await this.dbp;
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, mode);
transaction.oncomplete = () => resolve();
transaction.onabort = (ev) => {
gaException({ type: 'IDBTransactionAbort', ev });
reject(ev);
};
transaction.onerror = () => {
gaException({ type: 'IDBTransaction', err: transaction.error });
reject(transaction.error);
};
setTimeout(() => reject('IDB transaction timeout'), 1000);
callback(transaction.objectStore(STORE_NAME));
});
}
}
8 changes: 8 additions & 0 deletions docs/shell/cdimage.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@ class ImageReaderBase {
this.image = file;
});
}
async calculateCacheKey(metadataFile) {
const buf = await readFileAsArrayBuffer(metadataFile);
const hash = await crypto.subtle.digest('SHA-1', buf);
this.cddaCacheKey = btoa(String.fromCharCode(...new Uint8Array(hash)));
}
}
class IsoReader extends ImageReaderBase {
readSector(sector) {
Expand Down Expand Up @@ -214,6 +219,7 @@ class ImgCueReader extends ImageReaderBase {
return this.readSequential(startSector * 2352, length, 2352, 2048, 16);
}
async parseCue(cueFile) {
await this.calculateCacheKey(cueFile);
let lines = (await readFileAsText(cueFile)).split('\n');
let currentTrack = null;
for (let line of lines) {
Expand All @@ -233,6 +239,7 @@ class ImgCueReader extends ImageReaderBase {
}
}
async parseCcd(ccdFile) {
await this.calculateCacheKey(ccdFile);
let lines = (await readFileAsText(ccdFile)).split('\n');
let currentTrack = null;
for (let line of lines) {
Expand Down Expand Up @@ -300,6 +307,7 @@ class MdfMdsReader extends ImageReaderBase {
this.tracks = [];
}
async parseMds(mdsFile) {
await this.calculateCacheKey(mdsFile);
let buf = await readFileAsArrayBuffer(mdsFile);
let signature = ASCIIArrayToString(new Uint8Array(buf, 0, 16));
if (signature !== 'MEDIA DESCRIPTOR')
Expand Down
66 changes: 66 additions & 0 deletions docs/worker/cddacacheworker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use strict";
importScripts('lame.js');
let lameModule;
const MAX_SAMPLES = 256 * 1024;
const PCM_BUF_SIZE = MAX_SAMPLES * 4;
const BUF_SIZE = MAX_SAMPLES * 1.25 + 7200;
const vbr_default = 4; // from lame.h
const VBR_QUALITY = 5;
const WAVE_HEADER_SIZE = 44;
class Lame {
constructor(module) {
this.module = module;
this.gfp = module._lame_init();
if (!this.gfp)
throw new Error('lame_init failed');
module._lame_set_num_channels(this.gfp, 2);
module._lame_set_in_samplerate(this.gfp, 44100);
module._lame_set_VBR(this.gfp, vbr_default);
module._lame_set_VBR_q(this.gfp, VBR_QUALITY);
const r = module._lame_init_params(this.gfp);
if (r < 0)
throw new Error('lame_init_params failed: ' + r);
const memoryBuffer = module.HEAP8.buffer;
this.pcmBuffer = new Int16Array(memoryBuffer, module._malloc(PCM_BUF_SIZE));
this.outputBuffer = new Uint8Array(memoryBuffer, module._malloc(BUF_SIZE));
}
encode(samples) {
const numSamples = samples.length / 2;
const outputChunks = [];
let chunkStart = 0;
while (chunkStart < numSamples) {
const chunkEnd = Math.min(chunkStart + MAX_SAMPLES, numSamples);
this.pcmBuffer.set(samples.slice(chunkStart * 2, chunkEnd * 2));
const n = this.module._lame_encode_buffer_interleaved(this.gfp, this.pcmBuffer.byteOffset, chunkEnd - chunkStart, this.outputBuffer.byteOffset, BUF_SIZE);
if (n < 0)
throw new Error("lame_encode_buffer_interleaved failed: " + n);
outputChunks.push(this.outputBuffer.slice(0, n).buffer);
chunkStart = chunkEnd;
}
const n = this.module._lame_encode_flush(this.gfp, this.outputBuffer.byteOffset, BUF_SIZE);
if (n < 0)
throw new Error("lame_encode_flush failed: " + n);
outputChunks.push(this.outputBuffer.slice(0, n).buffer);
return outputChunks;
}
free() {
this.module._free(this.pcmBuffer.byteOffset);
this.module._free(this.outputBuffer.byteOffset);
this.module._lame_close(this.gfp);
}
}
async function encode(track, data) {
if (!lameModule)
lameModule = await Module();
const lame = new Lame(lameModule);
const samples = new Int16Array(data, WAVE_HEADER_SIZE);
const start = performance.now();
const mp3data = lame.encode(samples);
lame.free();
const time = performance.now() - start;
self.postMessage({ track, time, data: mp3data }, mp3data);
}
onmessage = (e) => {
const { track, data } = e.data;
encode(track, data);
};
Loading

0 comments on commit 4ca526d

Please sign in to comment.