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: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ Per-PR attribution and contributor credits are published automatically on the co

## [Unreleased]
### Fixed
- Web UI waveform lifecycle cleanup: resize listeners/canvases no longer stack across generations, animation loop is start/stop safe, canvas resizes with the window.
- Web UI stream-to-file swap settles pending buffer operations so playback can't hang after seeking.
- Web UI improvements; better use of space, responsive components, stream-to-file swap settles pending buffer operations more cleanly.
- Web UI waveform lifecycle cleanup: waveform slowed and softened and made framerate-independent; respects `prefers-reduced-motion`.
- Downloads save as `{voice}_{timestamp}.{format}` instead of the temp name (#338). `/v1/download/{filename}` takes an optional `?name=` (sanitized, stored extension kept) and sets it in `Content-Disposition`, which also covers right-click "Save audio as".

## [v0.7.0] - 2026-07-31
### Added
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# <sub><sub>_`FastKoko`_ </sub></sub>
![repoglyph](https://repoglyph.net/remsky/Kokoro-FastAPI.svg?palette=neon&commits=40&detail=15&branch=master&prefix=1&border=1&skip_dirs=ui%2Cexamples%2Cscripts%2Cdev%2Cdepr_tests)
![repoglyph](https://repoglyph.net/remsky/Kokoro-FastAPI.svg?palette=light&commits=40&detail=15&branch=master&prefix=1&border=1&skip_dirs=ui%2Cexamples%2Cscripts%2Cdev%2Cdepr_tests)

[![Changelog](https://img.shields.io/badge/changelog-white)](./CHANGELOG.md) [![Tests](https://img.shields.io/badge/tests-100-darkgreen)]()
[![Coverage](https://img.shields.io/badge/coverage-58%25-tan)]()
Expand Down Expand Up @@ -157,9 +157,9 @@ with client.audio.speech.with_streaming_response.create(

- Web Interface: http://localhost:8880/web

<div align="center" style="display: flex; justify-content: center; gap: 10px;">
<img src="assets/docs-screenshot.png" width="42%" alt="API Documentation" style="border: 2px solid #333; padding: 10px;">
<img src="assets/webui-screenshot.png" width="42%" alt="Web UI Screenshot" style="border: 2px solid #333; padding: 10px;">
<div align="center">
<img src="assets/webui-screenshot.png" width="47.3%" alt="Web UI Screenshot">
<img src="assets/docs-screenshot.png" width="50.7%" alt="API Documentation">
</div>

</details>
Expand Down
43 changes: 36 additions & 7 deletions api/src/routers/openai_compatible.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import aiofiles
import numpy as np
import torch
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse, StreamingResponse
from loguru import logger

Expand Down Expand Up @@ -411,8 +411,37 @@ async def single_output():
)


# everything outside this set is collapsed out of a client-supplied save-as name
_DOWNLOAD_NAME_UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")
_DOWNLOAD_NAME_EXT = re.compile(r"\.[A-Za-z0-9]{1,5}$")
_DOWNLOAD_NAME_MAX_STEM = 100


def _resolve_download_name(requested: str | None, stored_name: str) -> str:
"""Resolve the save-as name for a download.

Strips a client-supplied name to a safe charset and always keeps the stored
file's real extension. Falls back to the stored (temp) name.
"""
if not requested:
return stored_name

stem = _DOWNLOAD_NAME_EXT.sub("", _DOWNLOAD_NAME_UNSAFE.sub("_", requested))
stem = stem.strip("._-")[:_DOWNLOAD_NAME_MAX_STEM].strip("._-")
if not stem:
return stored_name

return f"{stem}{os.path.splitext(stored_name)[1]}"


@router.get("/download/{filename}")
async def download_audio_file(filename: str):
async def download_audio_file(
filename: str,
name: str | None = Query(
None,
description="Preferred save-as name. Sanitized; the stored file's extension is kept.",
),
):
"""Download a generated audio file from temp storage"""
try:
from ..core.paths import _find_file, get_content_type
Expand All @@ -425,14 +454,14 @@ async def download_audio_file(filename: str):
# Get content type from path helper
content_type = await get_content_type(file_path)

# browsers honor Content-Disposition over an anchor's download attribute
download_name = _resolve_download_name(name, os.path.basename(file_path))

return FileResponse(
file_path,
media_type=content_type,
filename=filename,
headers={
"Cache-Control": "no-cache",
"Content-Disposition": f"attachment; filename={filename}",
},
filename=download_name,
headers={"Cache-Control": "no-cache"},
)

except FileNotFoundError:
Expand Down
75 changes: 75 additions & 0 deletions api/tests/test_openai_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from api.src.inference.base import AudioChunk
from api.src.main import app
from api.src.routers.openai_compatible import (
_resolve_download_name,
get_tts_service,
load_openai_mappings,
stream_audio_chunks,
Expand Down Expand Up @@ -500,3 +501,77 @@ async def mock_error_stream(*args, **kwargs):

writer.close()
assert "Failed to initialize stream" in str(exc.value)


@pytest.mark.parametrize(
"requested,expected",
[
(None, "tmprloey00i.mp3"),
("", "tmprloey00i.mp3"),
(
"af_bella_2026-08-01T12-30-00-000Z.mp3",
"af_bella_2026-08-01T12-30-00-000Z.mp3",
),
("af_bella+af_sky", "af_bella_af_sky.mp3"),
("report.wav", "report.mp3"), # extension always comes from the stored file
("../../etc/passwd", "etc_passwd.mp3"),
("sub/dir/name", "sub_dir_name.mp3"),
('bad";name', "bad_name.mp3"),
("...", "tmprloey00i.mp3"),
("x" * 200, f"{'x' * 100}.mp3"),
],
)
def test_resolve_download_name(requested, expected):
"""Client-supplied save-as names are sanitized and keep the stored extension"""
assert _resolve_download_name(requested, "tmprloey00i.mp3") == expected


@pytest.fixture
def temp_download_file(tmp_path):
"""A stored temp audio file plus its patched temp dir"""
audio_file = tmp_path / "tmprloey00i.mp3"
audio_file.write_bytes(b"fake mp3 bytes")
with patch("api.src.routers.openai_compatible.settings") as mock_settings:
mock_settings.temp_file_dir = str(tmp_path)
yield audio_file


def test_download_uses_temp_name_by_default(temp_download_file):
"""Without ?name= the stored temp name is served"""
response = client.get("/v1/download/tmprloey00i.mp3")

assert response.status_code == 200
assert response.headers["content-type"] == "audio/mpeg"
assert "tmprloey00i.mp3" in response.headers["content-disposition"]


def test_download_honors_requested_name(temp_download_file):
"""?name= drives Content-Disposition so the save dialog shows a friendly name"""
response = client.get(
"/v1/download/tmprloey00i.mp3",
params={"name": "af_bella_2026-08-01T12-30-00-000Z.mp3"},
)

assert response.status_code == 200
disposition = response.headers["content-disposition"]
assert "af_bella_2026-08-01T12-30-00-000Z.mp3" in disposition
assert "tmprloey00i" not in disposition


def test_download_rejects_traversal_in_requested_name(temp_download_file):
"""A path-like ?name= can't escape into a directory or swap the extension"""
response = client.get(
"/v1/download/tmprloey00i.mp3", params={"name": "../../evil.sh"}
)

assert response.status_code == 200
disposition = response.headers["content-disposition"]
assert "evil.mp3" in disposition
assert "/" not in disposition and ".." not in disposition


def test_download_missing_file_returns_404(temp_download_file):
"""Unknown temp names still 404"""
response = client.get("/v1/download/nope.mp3")

assert response.status_code == 404
Binary file modified assets/docs-screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/webui-screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 4 additions & 2 deletions playwright.config.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { defineConfig } from '@playwright/test';

const port = Number(process.env.PLAYWRIGHT_STATIC_PORT || 4173);

export default defineConfig({
testDir: './web/tests/e2e',
timeout: 30_000,
use: {
baseURL: 'http://127.0.0.1:4173',
baseURL: `http://127.0.0.1:${port}`,
},
webServer: {
command: 'node web/tests/e2e/fixtures/static-server.mjs',
url: 'http://127.0.0.1:4173',
url: `http://127.0.0.1:${port}`,
reuseExistingServer: true,
timeout: 10_000,
},
Expand Down
Binary file removed web/bmc-button.png
Binary file not shown.
16 changes: 16 additions & 0 deletions web/bmc-logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading