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:
Admin
2026-03-01 15:41:23 +05:00
parent a4def535d8
commit 286c30696f

View File

@@ -837,7 +837,22 @@ const chapterTmpl = `
speedSlider.disabled = false; 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() { function flushQueue() {
if (appending || !srcBuf || srcBuf.updating || queue.length === 0) return; if (appending || !srcBuf || srcBuf.updating || queue.length === 0) return;
@@ -895,7 +910,6 @@ const chapterTmpl = `
function startPrefetch() { function startPrefetch() {
if (!KOKORO_URL || !NEXT_N) return; 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) fetch('/ui/chapter-text/' + SLUG + '/' + NEXT_N)
.then(function (res) { return res.ok ? res.text() : Promise.reject(res.status); }) .then(function (res) { return res.ok ? res.text() : Promise.reject(res.status); })
.then(function (text) { .then(function (text) {
@@ -903,74 +917,90 @@ const chapterTmpl = `
if (!text) return; if (!text) return;
prefetchAbort = new AbortController(); prefetchAbort = new AbortController();
prefetchMSE = new MediaSource();
prefetchQueue = [];
prefetchAppending = false;
prefetchDone = false;
prefetchReady = false;
prefetchAudio = document.createElement('audio'); prefetchAudio = document.createElement('audio');
prefetchAudio.style.display = 'none'; prefetchAudio.style.display = 'none';
prefetchAudio.src = URL.createObjectURL(prefetchMSE); prefetchReady = false;
prefetchAudio.addEventListener('canplay', function () { prefetchAudio.addEventListener('canplay', function () { prefetchReady = true; });
prefetchReady = true;
});
prefetchMSE.addEventListener('sourceopen', function () { if (USE_MSE) {
try { // ── MSE prefetch path ────────────────────────────────────────────────
prefetchBuf = prefetchMSE.addSourceBuffer('audio/mpeg'); prefetchMSE = new MediaSource();
} catch (e) { return; } prefetchQueue = [];
prefetchAppending = false;
prefetchDone = false;
prefetchBuf.addEventListener('updateend', function () { prefetchAudio.src = URL.createObjectURL(prefetchMSE);
prefetchAppending = false;
if (prefetchDone && prefetchQueue.length === 0) { prefetchMSE.addEventListener('sourceopen', function () {
if (prefetchMSE.readyState === 'open' && !prefetchBuf.updating) { try {
try { prefetchMSE.endOfStream(); } catch(_) {} 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', { fetch(KOKORO_URL + '/v1/audio/speech', {
method: 'POST', method: 'POST',
signal: prefetchAbort.signal, signal: prefetchAbort.signal,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: 'kokoro', model: 'kokoro', input: text, voice: voiceSel.value,
input: text, response_format: 'mp3', speed: parseFloat(speedSlider.value), stream: false
voice: voiceSel.value,
response_format: 'mp3',
speed: parseFloat(speedSlider.value),
stream: true
}) })
}) })
.then(function (res) { .then(function (res) { return res.ok ? res.blob() : Promise.reject(res.status); })
if (!res.ok) return; .then(function (blob) {
var reader = res.body.getReader(); prefetchAudio.src = URL.createObjectURL(blob);
function pump() { prefetchDone = true;
reader.read().then(function (ref) { prefetchAudio.load();
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 () {}); .catch(function () {});
}); }
prefetchAudio.load();
}) })
.catch(function () {}); .catch(function () {});
} }
@@ -994,70 +1024,104 @@ const chapterTmpl = `
function startStream(text, paraIdx) { function startStream(text, paraIdx) {
abortCtrl = new AbortController(); 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 () { audio.src = URL.createObjectURL(mse);
try {
srcBuf = mse.addSourceBuffer('audio/mpeg');
} catch (e) {
setError('MSE: ' + e.message);
return;
}
srcBuf.addEventListener('updateend', function () { mse.addEventListener('sourceopen', function () {
appending = false; try {
if (fetchDone && queue.length === 0) { srcBuf = mse.addSourceBuffer('audio/mpeg');
endStream(); } catch (e) {
} else { setError('MSE: ' + e.message);
flushQueue(); 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', { fetch(KOKORO_URL + '/v1/audio/speech', {
method: 'POST', method: 'POST',
signal: abortCtrl.signal, signal: abortCtrl.signal,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: 'kokoro', model: 'kokoro', input: text, voice: voiceSel.value,
input: text, response_format: 'mp3', speed: parseFloat(speedSlider.value), stream: false
voice: voiceSel.value,
response_format: 'mp3',
speed: parseFloat(speedSlider.value),
stream: true
}) })
}) })
.then(function (res) { .then(function (res) {
if (!res.ok) { if (!res.ok) {
return res.text().then(function (t) { throw new Error(res.status + ': ' + t); }); return res.text().then(function (t) { throw new Error(res.status + ': ' + t); });
} }
var reader = res.body.getReader(); return res.blob();
function pump() { })
reader.read().then(function (ref) { .then(function (blob) {
if (ref.done) { if (abortCtrl && abortCtrl.signal.aborted) return;
fetchDone = true; var url = URL.createObjectURL(blob);
if (!srcBuf.updating && queue.length === 0) { endStream(); } audio.src = url;
return; audio.load();
} // Trigger prefetch now that the main download is done.
queue.push(ref.value); if (autoplayChk.checked && NEXT_N && !prefetchAbort) {
flushQueue(); startPrefetch();
pump();
}).catch(function (e) {
if (e.name !== 'AbortError') setError(e.message);
});
} }
pump();
}) })
.catch(function (e) { .catch(function (e) {
if (e.name !== 'AbortError') setError(e.message); if (e.name !== 'AbortError') setError(e.message);
}); });
}); }
audio.load();
} }
// startFromPara stops any current stream and begins a new one from paraIdx. // startFromPara stops any current stream and begins a new one from paraIdx.