diff --git a/CHANGELOG.md b/CHANGELOG.md
index d8bee9b3..8f1f965f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 2183c3c1..fd98e456 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
# _`FastKoko`_
-
+
[](./CHANGELOG.md) []()
[]()
@@ -157,9 +157,9 @@ with client.audio.speech.with_streaming_response.create(
- Web Interface: http://localhost:8880/web
-
-
-
+
+
+
diff --git a/api/src/routers/openai_compatible.py b/api/src/routers/openai_compatible.py
index 42b4a761..c7d25786 100644
--- a/api/src/routers/openai_compatible.py
+++ b/api/src/routers/openai_compatible.py
@@ -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
@@ -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
@@ -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:
diff --git a/api/tests/test_openai_endpoints.py b/api/tests/test_openai_endpoints.py
index f682c9f1..a122c1b0 100644
--- a/api/tests/test_openai_endpoints.py
+++ b/api/tests/test_openai_endpoints.py
@@ -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,
@@ -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
diff --git a/assets/docs-screenshot.png b/assets/docs-screenshot.png
index d90bd5f9..572f2c9f 100644
Binary files a/assets/docs-screenshot.png and b/assets/docs-screenshot.png differ
diff --git a/assets/webui-screenshot.png b/assets/webui-screenshot.png
index 3fe9d267..6f0fd578 100644
Binary files a/assets/webui-screenshot.png and b/assets/webui-screenshot.png differ
diff --git a/playwright.config.mjs b/playwright.config.mjs
index 3e6c8e63..38cd03ae 100644
--- a/playwright.config.mjs
+++ b/playwright.config.mjs
@@ -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,
},
diff --git a/web/bmc-button.png b/web/bmc-button.png
deleted file mode 100644
index fc581f23..00000000
Binary files a/web/bmc-button.png and /dev/null differ
diff --git a/web/bmc-logo.svg b/web/bmc-logo.svg
new file mode 100644
index 00000000..68c86fa4
--- /dev/null
+++ b/web/bmc-logo.svg
@@ -0,0 +1,16 @@
+
diff --git a/web/index.html b/web/index.html
index e5ce5395..0d36650d 100644
--- a/web/index.html
+++ b/web/index.html
@@ -3,99 +3,94 @@
+
FastKoko: Kokoro-based TTS
-
-
-
-
-
+
+
+
-
-
+
+
-