fix: fall back to full blob download on iOS where MSE is unsupported
iOS Safari does not reliably support MediaSource for audio/mpeg streaming, causing an 'audio decode error' when the MSE SourceBuffer path is used. Detect iOS (and any browser without audio/mpeg MSE support) at runtime and fall back to a single fetch(...).blob() download with stream:false. Playback begins after the full chapter audio is received rather than mid-stream, but it works on all devices. Desktop browsers keep the existing MSE streaming path (stream:true) for faster time-to-first-audio. The same detection applies to the next-chapter prefetch pipeline.
This commit is contained in:
@@ -837,7 +837,22 @@ const chapterTmpl = `
|
||||
speedSlider.disabled = false;
|
||||
}
|
||||
|
||||
// ── MSE helpers ───────────────────────────────────────────────────────────────
|
||||
// ── capability detection ──────────────────────────────────────────────────────
|
||||
// MSE streaming is not supported on iOS Safari (or is unreliable on older
|
||||
// versions). Fall back to a full-download blob URL on those platforms so
|
||||
// audio works on all devices. The downside is playback starts only after the
|
||||
// full chapter audio has been downloaded, but it is universally compatible.
|
||||
|
||||
var USE_MSE = (function () {
|
||||
if (typeof MediaSource === 'undefined') return false;
|
||||
// iOS Safari reports MediaSource but does not support it reliably for audio/mpeg
|
||||
var ua = navigator.userAgent;
|
||||
var isIOS = /iPad|iPhone|iPod/.test(ua) && !window.MSStream;
|
||||
if (isIOS) return false;
|
||||
try { return MediaSource.isTypeSupported('audio/mpeg'); } catch(_) { return false; }
|
||||
})();
|
||||
|
||||
// ── MSE helpers (desktop / non-iOS) ──────────────────────────────────────────
|
||||
|
||||
function flushQueue() {
|
||||
if (appending || !srcBuf || srcBuf.updating || queue.length === 0) return;
|
||||
@@ -895,7 +910,6 @@ const chapterTmpl = `
|
||||
function startPrefetch() {
|
||||
if (!KOKORO_URL || !NEXT_N) return;
|
||||
|
||||
// Fetch the next chapter's plain text from the server's chapter-text endpoint.
|
||||
fetch('/ui/chapter-text/' + SLUG + '/' + NEXT_N)
|
||||
.then(function (res) { return res.ok ? res.text() : Promise.reject(res.status); })
|
||||
.then(function (text) {
|
||||
@@ -903,74 +917,90 @@ const chapterTmpl = `
|
||||
if (!text) return;
|
||||
|
||||
prefetchAbort = new AbortController();
|
||||
prefetchMSE = new MediaSource();
|
||||
prefetchQueue = [];
|
||||
prefetchAppending = false;
|
||||
prefetchDone = false;
|
||||
prefetchReady = false;
|
||||
|
||||
prefetchAudio = document.createElement('audio');
|
||||
prefetchAudio.style.display = 'none';
|
||||
prefetchAudio.src = URL.createObjectURL(prefetchMSE);
|
||||
prefetchReady = false;
|
||||
|
||||
prefetchAudio.addEventListener('canplay', function () {
|
||||
prefetchReady = true;
|
||||
});
|
||||
prefetchAudio.addEventListener('canplay', function () { prefetchReady = true; });
|
||||
|
||||
prefetchMSE.addEventListener('sourceopen', function () {
|
||||
try {
|
||||
prefetchBuf = prefetchMSE.addSourceBuffer('audio/mpeg');
|
||||
} catch (e) { return; }
|
||||
if (USE_MSE) {
|
||||
// ── MSE prefetch path ────────────────────────────────────────────────
|
||||
prefetchMSE = new MediaSource();
|
||||
prefetchQueue = [];
|
||||
prefetchAppending = false;
|
||||
prefetchDone = false;
|
||||
|
||||
prefetchBuf.addEventListener('updateend', function () {
|
||||
prefetchAppending = false;
|
||||
if (prefetchDone && prefetchQueue.length === 0) {
|
||||
if (prefetchMSE.readyState === 'open' && !prefetchBuf.updating) {
|
||||
try { prefetchMSE.endOfStream(); } catch(_) {}
|
||||
prefetchAudio.src = URL.createObjectURL(prefetchMSE);
|
||||
|
||||
prefetchMSE.addEventListener('sourceopen', function () {
|
||||
try {
|
||||
prefetchBuf = prefetchMSE.addSourceBuffer('audio/mpeg');
|
||||
} catch (e) { return; }
|
||||
|
||||
prefetchBuf.addEventListener('updateend', function () {
|
||||
prefetchAppending = false;
|
||||
if (prefetchDone && prefetchQueue.length === 0) {
|
||||
if (prefetchMSE.readyState === 'open' && !prefetchBuf.updating) {
|
||||
try { prefetchMSE.endOfStream(); } catch(_) {}
|
||||
}
|
||||
} else {
|
||||
flushPrefetchQueue();
|
||||
}
|
||||
} else {
|
||||
flushPrefetchQueue();
|
||||
}
|
||||
});
|
||||
|
||||
fetch(KOKORO_URL + '/v1/audio/speech', {
|
||||
method: 'POST',
|
||||
signal: prefetchAbort.signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'kokoro', input: text, voice: voiceSel.value,
|
||||
response_format: 'mp3', speed: parseFloat(speedSlider.value), stream: true
|
||||
})
|
||||
})
|
||||
.then(function (res) {
|
||||
if (!res.ok) return;
|
||||
var reader = res.body.getReader();
|
||||
function pump() {
|
||||
reader.read().then(function (ref) {
|
||||
if (ref.done) {
|
||||
prefetchDone = true;
|
||||
if (prefetchBuf && !prefetchBuf.updating && prefetchQueue.length === 0) {
|
||||
if (prefetchMSE && prefetchMSE.readyState === 'open') {
|
||||
try { prefetchMSE.endOfStream(); } catch(_) {}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
prefetchQueue.push(ref.value);
|
||||
flushPrefetchQueue();
|
||||
pump();
|
||||
}).catch(function () {});
|
||||
}
|
||||
pump();
|
||||
})
|
||||
.catch(function () {});
|
||||
});
|
||||
|
||||
prefetchAudio.load();
|
||||
} else {
|
||||
// ── Blob prefetch path (iOS / no MSE) ────────────────────────────────
|
||||
fetch(KOKORO_URL + '/v1/audio/speech', {
|
||||
method: 'POST',
|
||||
signal: prefetchAbort.signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'kokoro',
|
||||
input: text,
|
||||
voice: voiceSel.value,
|
||||
response_format: 'mp3',
|
||||
speed: parseFloat(speedSlider.value),
|
||||
stream: true
|
||||
model: 'kokoro', input: text, voice: voiceSel.value,
|
||||
response_format: 'mp3', speed: parseFloat(speedSlider.value), stream: false
|
||||
})
|
||||
})
|
||||
.then(function (res) {
|
||||
if (!res.ok) return;
|
||||
var reader = res.body.getReader();
|
||||
function pump() {
|
||||
reader.read().then(function (ref) {
|
||||
if (ref.done) {
|
||||
prefetchDone = true;
|
||||
if (prefetchBuf && !prefetchBuf.updating && prefetchQueue.length === 0) {
|
||||
if (prefetchMSE && prefetchMSE.readyState === 'open') {
|
||||
try { prefetchMSE.endOfStream(); } catch(_) {}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
prefetchQueue.push(ref.value);
|
||||
flushPrefetchQueue();
|
||||
pump();
|
||||
}).catch(function () {});
|
||||
}
|
||||
pump();
|
||||
.then(function (res) { return res.ok ? res.blob() : Promise.reject(res.status); })
|
||||
.then(function (blob) {
|
||||
prefetchAudio.src = URL.createObjectURL(blob);
|
||||
prefetchDone = true;
|
||||
prefetchAudio.load();
|
||||
})
|
||||
.catch(function () {});
|
||||
});
|
||||
|
||||
prefetchAudio.load();
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
@@ -994,70 +1024,104 @@ const chapterTmpl = `
|
||||
|
||||
function startStream(text, paraIdx) {
|
||||
abortCtrl = new AbortController();
|
||||
mse = new MediaSource();
|
||||
queue = [];
|
||||
appending = false;
|
||||
fetchDone = false;
|
||||
|
||||
audio.src = URL.createObjectURL(mse);
|
||||
if (USE_MSE) {
|
||||
// ── MSE streaming path (desktop) ─────────────────────────────────────────
|
||||
mse = new MediaSource();
|
||||
queue = [];
|
||||
appending = false;
|
||||
fetchDone = false;
|
||||
|
||||
mse.addEventListener('sourceopen', function () {
|
||||
try {
|
||||
srcBuf = mse.addSourceBuffer('audio/mpeg');
|
||||
} catch (e) {
|
||||
setError('MSE: ' + e.message);
|
||||
return;
|
||||
}
|
||||
audio.src = URL.createObjectURL(mse);
|
||||
|
||||
srcBuf.addEventListener('updateend', function () {
|
||||
appending = false;
|
||||
if (fetchDone && queue.length === 0) {
|
||||
endStream();
|
||||
} else {
|
||||
flushQueue();
|
||||
mse.addEventListener('sourceopen', function () {
|
||||
try {
|
||||
srcBuf = mse.addSourceBuffer('audio/mpeg');
|
||||
} catch (e) {
|
||||
setError('MSE: ' + e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
srcBuf.addEventListener('updateend', function () {
|
||||
appending = false;
|
||||
if (fetchDone && queue.length === 0) {
|
||||
endStream();
|
||||
} else {
|
||||
flushQueue();
|
||||
}
|
||||
});
|
||||
|
||||
fetch(KOKORO_URL + '/v1/audio/speech', {
|
||||
method: 'POST',
|
||||
signal: abortCtrl.signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'kokoro', input: text, voice: voiceSel.value,
|
||||
response_format: 'mp3', speed: parseFloat(speedSlider.value), stream: true
|
||||
})
|
||||
})
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
return res.text().then(function (t) { throw new Error(res.status + ': ' + t); });
|
||||
}
|
||||
var reader = res.body.getReader();
|
||||
function pump() {
|
||||
reader.read().then(function (ref) {
|
||||
if (ref.done) {
|
||||
fetchDone = true;
|
||||
if (!srcBuf.updating && queue.length === 0) { endStream(); }
|
||||
return;
|
||||
}
|
||||
queue.push(ref.value);
|
||||
flushQueue();
|
||||
pump();
|
||||
}).catch(function (e) {
|
||||
if (e.name !== 'AbortError') setError(e.message);
|
||||
});
|
||||
}
|
||||
pump();
|
||||
})
|
||||
.catch(function (e) {
|
||||
if (e.name !== 'AbortError') setError(e.message);
|
||||
});
|
||||
});
|
||||
|
||||
audio.load();
|
||||
} else {
|
||||
// ── Blob path (iOS / no MSE) ──────────────────────────────────────────────
|
||||
// Fetch the full audio as a single blob then hand it to the <audio> element.
|
||||
// Playback begins only once the download is complete, but this is the only
|
||||
// approach that works reliably on iOS Safari.
|
||||
status.textContent = 'Downloading\u2026';
|
||||
fetch(KOKORO_URL + '/v1/audio/speech', {
|
||||
method: 'POST',
|
||||
signal: abortCtrl.signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'kokoro',
|
||||
input: text,
|
||||
voice: voiceSel.value,
|
||||
response_format: 'mp3',
|
||||
speed: parseFloat(speedSlider.value),
|
||||
stream: true
|
||||
model: 'kokoro', input: text, voice: voiceSel.value,
|
||||
response_format: 'mp3', speed: parseFloat(speedSlider.value), stream: false
|
||||
})
|
||||
})
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
return res.text().then(function (t) { throw new Error(res.status + ': ' + t); });
|
||||
}
|
||||
var reader = res.body.getReader();
|
||||
function pump() {
|
||||
reader.read().then(function (ref) {
|
||||
if (ref.done) {
|
||||
fetchDone = true;
|
||||
if (!srcBuf.updating && queue.length === 0) { endStream(); }
|
||||
return;
|
||||
}
|
||||
queue.push(ref.value);
|
||||
flushQueue();
|
||||
pump();
|
||||
}).catch(function (e) {
|
||||
if (e.name !== 'AbortError') setError(e.message);
|
||||
});
|
||||
return res.blob();
|
||||
})
|
||||
.then(function (blob) {
|
||||
if (abortCtrl && abortCtrl.signal.aborted) return;
|
||||
var url = URL.createObjectURL(blob);
|
||||
audio.src = url;
|
||||
audio.load();
|
||||
// Trigger prefetch now that the main download is done.
|
||||
if (autoplayChk.checked && NEXT_N && !prefetchAbort) {
|
||||
startPrefetch();
|
||||
}
|
||||
pump();
|
||||
})
|
||||
.catch(function (e) {
|
||||
if (e.name !== 'AbortError') setError(e.message);
|
||||
});
|
||||
});
|
||||
|
||||
audio.load();
|
||||
}
|
||||
}
|
||||
|
||||
// startFromPara stops any current stream and begins a new one from paraIdx.
|
||||
|
||||
Reference in New Issue
Block a user