Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,10 @@ Per-PR attribution and contributor credits are published automatically on the co
## [Unreleased]
### Added
- `POST /dev/unload` release model from VRAM without stopping container; lazy reload on next request. For freeing a shared GPU while idle. Reclaim scale with load (~0.7 GB; ~1.6 GB via long-form test on 4060Ti). (#474)

### Fixed
- Web UI long-playback bugfix around the 10-minute mark; in-browser audio buffer is now bounded ahead of `currentTime` with trailing eviction behind it, so long generations stop overflowing the SourceBuffer.
- Web UI stays responsive on extended sessions; waveform animation is transition-gated and `PlayerState` short-circuits no-op updates, so controls don't drift into lag after 10+ minutes of playback.

### Notes
- Scrubbing may not be fully not supported in current state on MP3 streamed playback. WAV etc, plays back fine on completion.
- Web UI MP3 seek/scrub works after stream completes; pausing or playback end auto-swaps to the full server file, allowing timeline navigation.

## [v0.4.0] - 2026-05-24
### Added
Expand Down
10 changes: 10 additions & 0 deletions api/src/core/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,16 @@ async def get_content_type(path: str) -> str:
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
# audio downloads: serve a real media type so the webui can play the file
# directly (the player swaps to this URL once generation finishes, #150).
".mp3": "audio/mpeg",
".wav": "audio/wav",
".opus": "audio/opus",
".flac": "audio/flac",
".aac": "audio/aac",
".m4a": "audio/mp4",
".ogg": "audio/ogg",
".pcm": "audio/pcm",
}.get(ext, "application/octet-stream")


Expand Down
8 changes: 8 additions & 0 deletions api/tests/test_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ async def test_get_content_type():
("test.css", "text/css"),
("test.png", "image/png"),
("test.unknown", "application/octet-stream"),
("test.mp3", "audio/mpeg"),
("test.wav", "audio/wav"),
("test.opus", "audio/opus"),
("test.flac", "audio/flac"),
("test.aac", "audio/aac"),
("test.ogg", "audio/ogg"),
("test.m4a", "audio/mp4"),
("test.pcm", "audio/pcm"),
]

for filename, expected in test_cases:
Expand Down
2 changes: 1 addition & 1 deletion web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ <h1>FastKoko</h1>
</div>
<footer class="page-footer">
<a href="https://www.buymeacoffee.com/remsky" target="_blank" rel="noopener" class="bmc-link" aria-label="Buy me a coffee">
<img src="https://img.buymeacoffee.com/button-api/?text=Buy me a coffee&emoji=☕&slug=remsky&button_colour=6366F1&font_colour=000000&outline_colour=0f172a&coffee_colour=FFDD00" alt="Buy me a coffee">
<img src="https://img.buymeacoffee.com/button-api/?text=Buy me a coffee&emoji=☕&slug=remsky&button_colour=222222&font_colour=ffffff&outline_colour=111111&coffee_colour=FFDD00" alt="Buy me a coffee">
</a>
</footer>
<a id="version-badge" class="version-badge" href="https://github.com/remsky/Kokoro-FastAPI/releases" target="_blank" rel="noopener" hidden></a>
Expand Down
5 changes: 4 additions & 1 deletion web/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export class App {
message = isFirefox
? 'Audio streaming is not currently supported in Firefox. Playback and/or download should stilll be available when generation finishes.'
: 'This browser may not support streaming. Playback and/or download should still be available when generation finishes.';
} else if (this.elements.autoplayToggle?.checked) {
message = 'Auto-play on: pause after generation completes to enable full seek/scrub.';
}

notice.textContent = message;
Expand All @@ -105,8 +107,9 @@ export class App {
// Download button
this.elements.downloadBtn.addEventListener('click', () => this.downloadAudio());

// Keep browser/output warning aligned with the selected format
// Keep browser/output warning aligned with the selected format and autoplay state
this.elements.formatSelect.addEventListener('change', () => this.applyBrowserStreamingNotice());
this.elements.autoplayToggle.addEventListener('change', () => this.applyBrowserStreamingNotice());

// Cancel button
this.elements.cancelBtn.addEventListener('click', () => {
Expand Down
1 change: 1 addition & 0 deletions web/src/components/PlayerControls.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export class PlayerControls {
this.setupAudioEvents();
this.setupStateSubscription();
this.timeUpdateInterval = null;
this.updateControls(this.playerState.getState());
}

formatTime(secs) {
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/WaveVisualizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export class WaveVisualizer {
height: 100, // Increased height
autostart: false,
amplitude: 1,
speed: 0.1
speed: 0.03
});

// Handle window resize
Expand Down
112 changes: 112 additions & 0 deletions web/src/services/AudioService.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export class AudioService {
this.chunkQueue = [];
this.streamFinished = false;
this.feederWakeup = null;
// once an MSE generation finishes, the full file lives on the server. swapping
// the <audio> element over to it restores true duration + full seeking, which
// the bounded streaming buffer can't provide for long generations (#150).
this.usingFileSource = false;
this.swapInProgress = false;
}

supportsMSEMp3() {
Expand Down Expand Up @@ -183,6 +188,11 @@ export class AudioService {

this.audio.addEventListener('ended', () => {
this.dispatchEvent('ended');
// reaching the end is a safe break point too: swap so a replay/scrub uses
// the full file rather than the evicted streaming buffer.
if (this.canSwapToFileSource()) {
this.swapToFileSource();
}
});

return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -243,6 +253,12 @@ export class AudioService {

setTimeout(() => {
this.dispatchEvent('downloadReady');
// if the user isn't actively listening (autoplay off, or paused),
// switch to the full file now so duration + seeking are correct
// the moment generation finishes. otherwise defer to pause/ended.
if (this.audio && this.audio.paused) {
this.swapToFileSource();
}
}, 800);

return;
Expand Down Expand Up @@ -491,6 +507,11 @@ export class AudioService {
if (this.audio) {
this.audio.pause();
this.dispatchEvent('pause');
// pausing a finished generation is a safe break point: swap to the full
// file so the user can scrub the whole track and see the real duration.
if (this.canSwapToFileSource()) {
this.swapToFileSource();
}
}
}

Expand All @@ -504,6 +525,93 @@ export class AudioService {
}
}

// true only for a finished MSE stream that hasn't been swapped yet. block mode
// already plays a full-file blob, so it never needs (or gets) a swap.
canSwapToFileSource() {
return (
!this.usingFileSource &&
!this.swapInProgress &&
this.streamFinished &&
this.mediaSource !== null &&
!!this.serverDownloadPath &&
!!this.audio &&
!this.audio.error
);
}

// tear down the bounded MSE buffer and point the same <audio> element at the
// finished server file (FileResponse serves range requests, so duration and
// seeking become correct). reusing the element keeps every listener attached.
// only called at safe moments (idle on completion, on pause, on ended) so active
// playback is never interrupted mid-stream.
async swapToFileSource(targetTime = null, shouldPlay = false) {
if (!this.canSwapToFileSource()) {
return false;
}
this.swapInProgress = true;

const audio = this.audio;
const resumeTime = targetTime != null ? targetTime : (audio.currentTime || 0);
const volume = audio.volume;
const rate = audio.playbackRate;
const previousObjectUrl = this.objectUrl;
const fileUrl = this.serverDownloadPath;

// drop MSE references up front so a late feeder/updateend can't touch them.
this.mediaSource = null;
this.sourceBuffer = null;
this.objectUrl = null;

return await new Promise((resolve) => {
const detach = () => {
audio.removeEventListener('loadedmetadata', onLoaded);
audio.removeEventListener('error', onError);
};

const onLoaded = () => {
detach();
if (audio !== this.audio) { this.swapInProgress = false; resolve(false); return; }
const duration = audio.duration;
if (Number.isFinite(duration) && duration > 0) {
// 1:1 timeline (sequence mode appended the same bytes), so the
// playhead lands where the stream left off.
audio.currentTime = Math.min(Math.max(resumeTime, 0), Math.max(0, duration - 0.05));
}
audio.volume = volume;
audio.playbackRate = rate;
this.usingFileSource = true;
this.swapInProgress = false;
this.dispatchEvent('ready');
if (shouldPlay) {
this.play();
}
resolve(true);
};

const onError = () => {
detach();
if (audio !== this.audio) { this.swapInProgress = false; resolve(false); return; }
this.swapInProgress = false;
// the stream buffer is gone, but the file is still downloadable. surface
// that so the user isn't left with a dead player.
console.warn('Failed to switch to file playback:', audio.error);
this.dispatchEvent('playbackUnavailable');
resolve(false);
};

audio.addEventListener('loadedmetadata', onLoaded, { once: true });
audio.addEventListener('error', onError, { once: true });

audio.src = fileUrl;
audio.load();

// safe to revoke now that the element no longer references the MSE url.
if (previousObjectUrl) {
URL.revokeObjectURL(previousObjectUrl);
}
});
}

setVolume(volume) {
if (this.audio) {
this.audio.volume = Math.max(0, Math.min(1, volume));
Expand Down Expand Up @@ -594,6 +702,8 @@ export class AudioService {
this.rejectPendingOperations(new Error('AudioService cancelled'));
this.chunkQueue = [];
this.streamFinished = true;
this.usingFileSource = false;
this.swapInProgress = false;
this.wakeFeeder();
this.revokeObjectUrl();
}
Expand Down Expand Up @@ -624,6 +734,8 @@ export class AudioService {
this.rejectPendingOperations(new Error('AudioService cleanup'));
this.chunkQueue = [];
this.streamFinished = true;
this.usingFileSource = false;
this.swapInProgress = false;
this.wakeFeeder();
this.revokeObjectUrl();
}
Expand Down
Loading
Loading