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;
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user