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;
}
// ── 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.