feat: play cached TTS audio immediately on auto-start if already generated
This commit is contained in:
@@ -134,6 +134,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
|||||||
`{"error":"audio generation timed out"}`,
|
`{"error":"audio generation timed out"}`,
|
||||||
)
|
)
|
||||||
mux.Handle("POST /ui/audio/{slug}/{n}", audioGenHandler)
|
mux.Handle("POST /ui/audio/{slug}/{n}", audioGenHandler)
|
||||||
|
// Status route: returns the proxy URL if audio was already generated, 404 otherwise.
|
||||||
|
mux.HandleFunc("GET /ui/audio/{slug}/{n}", s.handleAudioStatus)
|
||||||
// Proxy route: fetches the generated file from Kokoro /v1/download/{filename}.
|
// Proxy route: fetches the generated file from Kokoro /v1/download/{filename}.
|
||||||
mux.HandleFunc("GET /ui/audio-proxy/{slug}/{n}", s.handleAudioProxy)
|
mux.HandleFunc("GET /ui/audio-proxy/{slug}/{n}", s.handleAudioProxy)
|
||||||
|
|
||||||
@@ -355,6 +357,39 @@ func (s *Server) writeAudioResponse(w http.ResponseWriter, slug string, n int, v
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleAudioStatus handles GET /ui/audio/{slug}/{n}.
|
||||||
|
// Returns the proxy URL if audio was already generated this session, 404 otherwise.
|
||||||
|
func (s *Server) handleAudioStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
n, err := strconv.Atoi(r.PathValue("n"))
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
http.Error(w, `{"error":"invalid chapter"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
voice := r.URL.Query().Get("voice")
|
||||||
|
if voice == "" {
|
||||||
|
voice = s.kokoroVoice
|
||||||
|
}
|
||||||
|
speedStr := r.URL.Query().Get("speed")
|
||||||
|
speed := 1.0
|
||||||
|
if speedStr != "" {
|
||||||
|
if v, err := strconv.ParseFloat(speedStr, 64); err == nil && v > 0 {
|
||||||
|
speed = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := fmt.Sprintf("%s/%d/%s/%.2f", slug, n, voice, speed)
|
||||||
|
s.audioMu.Lock()
|
||||||
|
filename, ok := s.audioCache[cacheKey]
|
||||||
|
s.audioMu.Unlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"not generated"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeAudioResponse(w, slug, n, voice, speed, filename)
|
||||||
|
}
|
||||||
|
|
||||||
// handleAudioProxy handles GET /ui/audio-proxy/{slug}/{n}.
|
// handleAudioProxy handles GET /ui/audio-proxy/{slug}/{n}.
|
||||||
// It looks up the Kokoro download filename for this chapter (voice/speed) and
|
// It looks up the Kokoro download filename for this chapter (voice/speed) and
|
||||||
// proxies GET /v1/download/{filename} from the Kokoro server back to the browser.
|
// proxies GET /v1/download/{filename} from the Kokoro server back to the browser.
|
||||||
|
|||||||
@@ -2922,21 +2922,43 @@ const chapterTmpl = `
|
|||||||
navigator.mediaSession.setActionHandler('seekto', function(d) { if (isFinite(d.seekTime)) { audio.currentTime = d.seekTime; updateSeek(); } });
|
navigator.mediaSession.setActionHandler('seekto', function(d) { if (isFinite(d.seekTime)) { audio.currentTime = d.seekTime; updateSeek(); } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadAndPlay(url) {
|
||||||
|
// Always set autoplay=true before load(): the browser needs this hint set
|
||||||
|
// synchronously before load() regardless of whether we got here via a user
|
||||||
|
// gesture or a programmatic trigger (e.g. chapter transition). The canplay
|
||||||
|
// handler will call play() as well, but autoplay on the element is the only
|
||||||
|
// reliable path after an async fetch where the gesture stack is gone.
|
||||||
|
audio.autoplay = true;
|
||||||
|
audio.src = url; audio.load();
|
||||||
|
audio.playbackRate = getSpeed();
|
||||||
|
registerMediaSession();
|
||||||
|
}
|
||||||
|
|
||||||
function startAudio(autoplay) {
|
function startAudio(autoplay) {
|
||||||
setGenerating();
|
setGenerating();
|
||||||
highlightPara(0);
|
highlightPara(0);
|
||||||
generateAudio(CHAPTER_N, function (url) {
|
// Check if audio was already generated for this chapter this session.
|
||||||
if (stale()) return;
|
// If so, play it directly without re-generating.
|
||||||
// Always set autoplay=true before load(): the browser needs this hint set
|
fetch('/ui/audio/' + SLUG + '/' + CHAPTER_N + '?voice=' + encodeURIComponent(voiceSel.value) + '&speed=1')
|
||||||
// synchronously before load() regardless of whether we got here via a user
|
.then(function(res) {
|
||||||
// gesture or a programmatic trigger (e.g. chapter transition). The canplay
|
if (stale()) return;
|
||||||
// handler will call play() as well, but autoplay on the element is the only
|
if (res.ok) {
|
||||||
// reliable path after an async fetch where the gesture stack is gone.
|
return res.json().then(function(data) {
|
||||||
audio.autoplay = true;
|
if (stale()) return;
|
||||||
audio.src = url; audio.load();
|
if (data && data.url) { loadAndPlay(data.url); return; }
|
||||||
audio.playbackRate = getSpeed();
|
throw new Error('no url in response');
|
||||||
registerMediaSession();
|
});
|
||||||
});
|
}
|
||||||
|
// Not cached yet — fall through to generation.
|
||||||
|
generateAudio(CHAPTER_N, function(url) {
|
||||||
|
if (stale()) return;
|
||||||
|
loadAndPlay(url);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
if (stale()) return;
|
||||||
|
setError(e.message);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.ttsToggle = function () {
|
window.ttsToggle = function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user