diff --git a/.codeql-config.yml b/.codeql-config.yml
new file mode 100644
index 00000000..b28c3bee
--- /dev/null
+++ b/.codeql-config.yml
@@ -0,0 +1,10 @@
+paths:
+ - api
+ - ui
+paths-ignore:
+ - .venv
+ - "**/node_modules"
+ - "**/__pycache__"
+ - examples
+ - docs
+ - "**/tests/**"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index abfcd7ed..3e7e8ba1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,14 +6,14 @@ on:
branches: [ "master", "pre-release" ]
jobs:
test:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
strategy:
matrix:
python-version: ["3.10"]
fail-fast: false
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
# Match Dockerfile dependencies
- name: Install Dependencies
@@ -34,9 +34,6 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
- - name: Install dependencies
- run: |
- uv pip install -e .[test,cpu]
- name: Run Tests
run: |
- uv run pytest api/tests/ --asyncio-mode=auto --cov=api --cov-report=term-missing
+ uv run --extra test --extra cpu pytest api/tests/ --asyncio-mode=auto --cov=api --cov-report=term-missing
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e4c12bfa..0f21d8b5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -14,26 +14,38 @@ on:
required: true
type: string
+concurrency:
+ group: release-${{ github.event_name == 'workflow_dispatch' && inputs.branch_name || github.ref_name }}
+ cancel-in-progress: false
+
jobs:
prepare-release:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
outputs:
version: ${{ steps.get-version.outputs.version }}
version_tag: ${{ steps.get-version.outputs.version_tag }}
+ source_ref: ${{ steps.resolve-ref.outputs.source_ref }}
steps:
+ - name: Resolve source ref
+ id: resolve-ref
+ run: |
+ if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
+ echo "source_ref=${{ inputs.branch_name }}" >> $GITHUB_OUTPUT
+ else
+ echo "source_ref=${{ github.ref_name }}" >> $GITHUB_OUTPUT
+ fi
+
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
+ with:
+ ref: ${{ steps.resolve-ref.outputs.source_ref }}
+ fetch-depth: 0
- name: Get version from VERSION file
id: get-version
run: |
VERSION_PLAIN=$(cat VERSION)
-
- if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
- BRANCH_NAME="${{ inputs.branch_name }}"
- else
- BRANCH_NAME="${{ github.ref_name }}"
- fi
+ BRANCH_NAME="${{ steps.resolve-ref.outputs.source_ref }}"
if [[ "$BRANCH_NAME" == "release" ]]; then
echo "version=${VERSION_PLAIN}" >> $GITHUB_OUTPUT
@@ -44,6 +56,15 @@ jobs:
echo "version_tag=v${VERSION_PLAIN}-${SAFE_BRANCH}" >> $GITHUB_OUTPUT
fi
+ - name: Check tag does not exist
+ run: |
+ TAG_NAME="${{ steps.get-version.outputs.version_tag }}"
+ git fetch --tags
+ if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
+ echo "::error::Tag $TAG_NAME already exists. Please increment the version in the VERSION file."
+ exit 1
+ fi
+
build-images:
needs: prepare-release
permissions:
@@ -60,10 +81,13 @@ jobs:
include:
- build_target: "cpu"
platform: "linux/amd64"
- runs_on: "ubuntu-latest"
+ runs_on: "ubuntu-24.04"
- build_target: "gpu"
platform: "linux/amd64"
- runs_on: "ubuntu-latest"
+ runs_on: "ubuntu-24.04"
+ - build_target: "gpu-cu128"
+ platform: "linux/amd64"
+ runs_on: "ubuntu-24.04"
- build_target: "cpu"
platform: "linux/arm64"
runs_on: "ubuntu-24.04-arm"
@@ -72,25 +96,13 @@ jobs:
runs_on: "ubuntu-24.04-arm"
- build_target: "rocm"
platform: "linux/amd64"
- runs_on: "ubuntu-latest"
+ runs_on: "ubuntu-24.04"
runs-on: ${{ matrix.runs_on }}
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
- fetch-depth: 0 # Needed to check for existing tags
-
- - name: Check if tag already exists
- run: |
- TAG_NAME="${{ needs.prepare-release.outputs.version_tag }}"
- echo "Checking for existing tag: $TAG_NAME"
- git fetch --tags
- if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
- echo "::error::Tag $TAG_NAME already exists. Please increment the version in the VERSION file."
- exit 1
- else
- echo "Tag $TAG_NAME does not exist. Proceeding with release."
- fi
+ ref: ${{ needs.prepare-release.outputs.source_ref }}
- name: Free disk space
run: |
@@ -103,14 +115,14 @@ jobs:
df -h
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3 # Use v3
+ uses: docker/setup-buildx-action@v4
with:
driver-opts: |
- image=moby/buildkit:latest
+ image=moby/buildkit:v0.21.1
network=host
- name: Log in to GitHub Container Registry
- uses: docker/login-action@v3 # Use v3
+ uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -127,11 +139,18 @@ jobs:
TARGET="${BUILD_TARGET}-$(echo ${PLATFORM} | cut -d'/' -f2)"
echo "Using bake target: $TARGET"
- docker buildx bake $TARGET --push --progress=plain
+ # Bake reads REVISION/CREATED as HCL variables; both populate
+ # org.opencontainers.image.* labels and annotations on the per-arch push.
+ export REVISION="${GITHUB_SHA}"
+ export CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+
+ docker buildx bake $TARGET --push --progress=plain \
+ --set "*.cache-from=type=gha,scope=${TARGET}" \
+ --set "*.cache-to=type=gha,mode=max,scope=${TARGET}"
create-manifests:
needs: [prepare-release, build-images]
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
permissions:
packages: write
env:
@@ -140,10 +159,10 @@ jobs:
REPO: ${{ vars.REPO || 'kokoro-fastapi' }}
strategy:
matrix:
- build_target: ["cpu", "gpu", "rocm"]
+ build_target: ["cpu", "gpu", "gpu-cu128", "rocm"]
steps:
- name: Log in to GitHub Container Registry
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -157,26 +176,107 @@ jobs:
OWNER="${{ env.OWNER }}"
REPO="${{ env.REPO }}"
- docker buildx imagetools create -t \
- ${REGISTRY}/${OWNER}/${REPO}-${TARGET}:${VERSION_TAG} \
- ${REGISTRY}/${OWNER}/${REPO}-${TARGET}:${VERSION_TAG}-amd64 \
- ${REGISTRY}/${OWNER}/${REPO}-${TARGET}:${VERSION_TAG}-arm64
+ # Resolve the package name, manifest tag(s), and source images per target.
+ #
+ # Tag layout per target:
+ # - cpu: :VERSION + :latest (multi-arch)
+ # - gpu: :VERSION + :VERSION-cu126 + :latest + :latest-cu126
+ # (the -cu126 aliases make the wheel variant explicit so
+ # Maxwell/Pascal users can pin it across future default flips)
+ # - gpu-cu128: :VERSION-cu128 + :latest-cu128 (amd64 only)
+ # - rocm: :VERSION + :latest (amd64 only)
+ #
+ # MANIFEST_TAGS / LATEST_TAGS are space-separated lists; all entries point at
+ # the same SOURCES so the underlying blobs are deduplicated by digest.
+ #
+ # TITLE / DESCRIPTION are attached as index-level OCI annotations.
+ # GHCR reads org.opencontainers.image.description from the index manifest
+ # for multi-arch packages, so the per-arch labels set by bake are not
+ # enough on their own.
+ case "$TARGET" in
+ gpu-cu128)
+ PACKAGE="${REPO}-gpu"
+ MANIFEST_TAGS="${VERSION_TAG}-cu128"
+ LATEST_TAGS="latest-cu128"
+ SOURCES=("${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-cu128-amd64")
+ TITLE="Kokoro-FastAPI (GPU, cu128 / Blackwell)"
+ DESCRIPTION="Kokoro TTS served via FastAPI. NVIDIA GPU build with CUDA 12.8 + cu128 torch wheels for RTX 50-series (Blackwell, sm_120). amd64 only."
+ ;;
+ gpu)
+ PACKAGE="${REPO}-gpu"
+ MANIFEST_TAGS="${VERSION_TAG} ${VERSION_TAG}-cu126"
+ LATEST_TAGS="latest latest-cu126"
+ # Per-arch sources carry their wheel variant (cu126 amd64, cu129 arm64);
+ # both feed the same multi-arch manifest tags above.
+ SOURCES=(
+ "${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-cu126-amd64"
+ "${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-cu129-arm64"
+ )
+ TITLE="Kokoro-FastAPI (GPU)"
+ DESCRIPTION="Kokoro TTS served via FastAPI. NVIDIA GPU build, multi-arch (CUDA 12.6 + cu126 wheels on amd64, CUDA 12.9 + cu129 wheels on arm64). See the -cu128 tag for Blackwell."
+ ;;
+ rocm)
+ PACKAGE="${REPO}-rocm"
+ MANIFEST_TAGS="${VERSION_TAG}"
+ LATEST_TAGS="latest"
+ SOURCES=("${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-amd64")
+ TITLE="Kokoro-FastAPI (ROCm)"
+ DESCRIPTION="Kokoro TTS served via FastAPI. AMD ROCm build. amd64 only."
+ ;;
+ *)
+ PACKAGE="${REPO}-${TARGET}"
+ MANIFEST_TAGS="${VERSION_TAG}"
+ LATEST_TAGS="latest"
+ SOURCES=(
+ "${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-amd64"
+ "${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-arm64"
+ )
+ TITLE="Kokoro-FastAPI (CPU)"
+ DESCRIPTION="Kokoro TTS served via FastAPI. CPU build, multi-arch (amd64 + arm64)."
+ ;;
+ esac
+
+ # Common index annotations. `index:` is required so these land on the
+ # index manifest itself, not on per-platform descriptors inside it.
+ SOURCE_URL="https://github.com/${OWNER}/Kokoro-FastAPI"
+ CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ REVISION="${GITHUB_SHA}"
+ ANNOTATIONS=(
+ --annotation "index:org.opencontainers.image.title=${TITLE}"
+ --annotation "index:org.opencontainers.image.description=${DESCRIPTION}"
+ --annotation "index:org.opencontainers.image.source=${SOURCE_URL}"
+ --annotation "index:org.opencontainers.image.url=${SOURCE_URL}"
+ --annotation "index:org.opencontainers.image.licenses=Apache-2.0"
+ --annotation "index:org.opencontainers.image.version=${VERSION_TAG}"
+ --annotation "index:org.opencontainers.image.revision=${REVISION}"
+ --annotation "index:org.opencontainers.image.created=${CREATED}"
+ )
+
+ for TAG in $MANIFEST_TAGS; do
+ docker buildx imagetools create \
+ "${ANNOTATIONS[@]}" \
+ -t ${REGISTRY}/${OWNER}/${PACKAGE}:${TAG} \
+ "${SOURCES[@]}"
+ done
+ # Publish the rolling tag(s) only for clean release tags (no branch suffix).
if [[ "$VERSION_TAG" != *"-"* ]]; then
- docker buildx imagetools create -t \
- ${REGISTRY}/${OWNER}/${REPO}-${TARGET}:latest \
- ${REGISTRY}/${OWNER}/${REPO}-${TARGET}:${VERSION_TAG}-amd64 \
- ${REGISTRY}/${OWNER}/${REPO}-${TARGET}:${VERSION_TAG}-arm64
+ for TAG in $LATEST_TAGS; do
+ docker buildx imagetools create \
+ "${ANNOTATIONS[@]}" \
+ -t ${REGISTRY}/${OWNER}/${PACKAGE}:${TAG} \
+ "${SOURCES[@]}"
+ done
fi
create-release:
needs: [prepare-release, create-manifests]
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
fetch-depth: 0
@@ -184,7 +284,10 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.prepare-release.outputs.version_tag }}
+ target_commitish: ${{ needs.prepare-release.outputs.source_ref }}
name: Release ${{ needs.prepare-release.outputs.version_tag }}
+ body: |
+ Curated summary: [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/${{ needs.prepare-release.outputs.version_tag }}/CHANGELOG.md)
generate_release_notes: true
draft: false
prerelease: false
diff --git a/.github/workflows/test_build.yml b/.github/workflows/test_build.yml
index 641ea5e6..1c294f0d 100644
--- a/.github/workflows/test_build.yml
+++ b/.github/workflows/test_build.yml
@@ -15,6 +15,7 @@ on:
- all
- cpu
- gpu
+ - gpu-cu128
- rocm
platform:
description: 'Platform (ignored for rocm)'
@@ -36,7 +37,7 @@ concurrency:
jobs:
setup:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
@@ -44,11 +45,12 @@ jobs:
id: set-matrix
run: |
ALL_TARGETS='[
- {"build_target":"cpu","platform":"amd64","runs_on":"ubuntu-latest"},
+ {"build_target":"cpu","platform":"amd64","runs_on":"ubuntu-24.04"},
{"build_target":"cpu","platform":"arm64","runs_on":"ubuntu-24.04-arm"},
- {"build_target":"gpu","platform":"amd64","runs_on":"ubuntu-latest"},
+ {"build_target":"gpu","platform":"amd64","runs_on":"ubuntu-24.04"},
{"build_target":"gpu","platform":"arm64","runs_on":"ubuntu-24.04-arm"},
- {"build_target":"rocm","platform":"amd64","runs_on":"ubuntu-latest"}
+ {"build_target":"gpu-cu128","platform":"amd64","runs_on":"ubuntu-24.04"},
+ {"build_target":"rocm","platform":"amd64","runs_on":"ubuntu-24.04"}
]'
FILTERED=$(echo "$ALL_TARGETS" | jq -c \
@@ -77,7 +79,7 @@ jobs:
runs-on: ${{ matrix.runs_on }}
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
ref: ${{ inputs.branch_name }}
@@ -86,15 +88,19 @@ jobs:
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
with:
driver-opts: image=moby/buildkit:v0.21.1
- name: Build image
run: |
TARGET="${{ matrix.build_target }}-${{ matrix.platform }}"
+ CACHE_ARGS=(
+ --set "*.cache-from=type=gha,scope=${TARGET}"
+ --set "*.cache-to=type=gha,mode=max,scope=${TARGET}"
+ )
if [[ "${{ inputs.dry_run }}" == "true" ]]; then
- docker buildx bake "$TARGET" --print
+ docker buildx bake "$TARGET" --print "${CACHE_ARGS[@]}"
else
- docker buildx bake "$TARGET" --progress=plain
+ docker buildx bake "$TARGET" --progress=plain "${CACHE_ARGS[@]}"
fi
diff --git a/.github/workflows/test_client_image.yml b/.github/workflows/test_client_image.yml
new file mode 100644
index 00000000..2bfdc563
--- /dev/null
+++ b/.github/workflows/test_client_image.yml
@@ -0,0 +1,84 @@
+name: Build & Publish TTS API Test Client Image
+
+# Publishes the TTS API test client image to GHCR.
+#
+# This image bakes in faster-whisper + Whisper small CT2 weights and the
+# audio-decoding stack (soundfile + libsndfile, pyav via faster-whisper) so
+# that `docker-compose.test.yml` can run end-to-end protocol + transcription
+# tests against a running server without touching Hugging Face at CI time.
+#
+# Lifecycle is independent of the app version. Rebuilt only when the
+# Dockerfile or pinned dep set changes, or on manual dispatch.
+
+on:
+ workflow_dispatch:
+ inputs:
+ image_tag:
+ description: 'Tag to publish (also always re-tagged as `latest`)'
+ required: true
+ type: string
+ default: 'v1'
+ push:
+ description: 'Push to registry (uncheck for a dry build)'
+ required: false
+ type: boolean
+ default: true
+ push:
+ branches: [master, release]
+ paths:
+ - 'docker/test-client/**'
+ - '.github/workflows/test_client_image.yml'
+
+concurrency:
+ group: test-client-image-${{ github.ref_name }}
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-24.04
+ permissions:
+ packages: write
+ contents: read
+ env:
+ REGISTRY: ${{ vars.REGISTRY || 'ghcr.io' }}
+ OWNER: ${{ vars.OWNER || 'remsky' }}
+ IMAGE_NAME: ${{ vars.TEST_CLIENT_IMAGE_NAME || 'tts-api-test-client' }}
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Resolve image refs
+ id: refs
+ run: |
+ BASE="${REGISTRY}/${OWNER}/${IMAGE_NAME}"
+ # Manual dispatch picks the tag; path-triggered builds always
+ # publish `latest` plus a short-sha tag for traceability.
+ if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
+ PRIMARY="${BASE}:${{ inputs.image_tag }}"
+ else
+ PRIMARY="${BASE}:sha-${GITHUB_SHA:0:7}"
+ fi
+ echo "primary=${PRIMARY}" >> "$GITHUB_OUTPUT"
+ echo "latest=${BASE}:latest" >> "$GITHUB_OUTPUT"
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v4
+
+ - name: Log in to GHCR
+ if: github.event_name != 'workflow_dispatch' || inputs.push
+ uses: docker/login-action@v4
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: docker/test-client
+ platforms: linux/amd64
+ push: ${{ github.event_name != 'workflow_dispatch' || inputs.push }}
+ tags: |
+ ${{ steps.refs.outputs.primary }}
+ ${{ steps.refs.outputs.latest }}
+ cache-from: type=gha,scope=test-client
+ cache-to: type=gha,mode=max,scope=test-client
diff --git a/.gitignore b/.gitignore
index 35fa9fb8..d772eec7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,7 @@ build/
# Environment
# .env
.venv/
+node_modules/
env/
venv/
ENV/
@@ -42,6 +43,8 @@ ENV/
# Other project files
.env
+.claude/*
+.agents/*
Kokoro-82M/
ui/data/
EXTERNAL_UV_DOCUMENTATION*
@@ -49,8 +52,7 @@ app
api/temp_files/
# Docker
-Dockerfile*
-docker-compose*
+# Dockerfile*
examples/ebook_test/chapter_to_audio.py
examples/ebook_test/chapters_to_audio.py
examples/ebook_test/parse_epub.py
@@ -69,7 +71,26 @@ examples/*.ogg
examples/speech.mp3
examples/phoneme_examples/output/*.wav
examples/assorted_checks/benchmarks/output_audio/*
+examples/assorted_checks/test_transcription/output/*.wav
+examples/assorted_checks/test_transcription/output_long_form/*.log
+examples/assorted_checks/test_transcription/output_long_form/*.transcript.txt
+examples/assorted_checks/test_transcription/output_long_form/*.wav
+examples/assorted_checks/test_transcription/output_long_form/*.synth_meta.json
+examples/assorted_checks/test_transcription/output_multilingual/*.wav
+examples/assorted_checks/benchmarks/output_data/model_unload_stats.txt
+examples/assorted_checks/benchmarks/output_data/model_unload_results.json
+examples/assorted_checks/benchmarks/output_plots/model_unload_longform.png
+examples/assorted_checks/benchmarks/output_plots/model_unload_short.png
uv.lock
+!docker/test-client/uv.lock
# Mac MPS virtualenv for dual testing
.venv-mps
+examples/assorted_checks/test_transcription/*/*.wav
+pyproject.toml.bkp
+
+# Local scratch notes, scan outputs, anything not meant to ship
+.local/
+examples/assorted_checks/test_silence/out/*
+playwright-report/
+test-results/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c076c282..34ef8310 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,36 +2,156 @@
Notable changes to this project will be documented in this file.
-## [v0.3.0] - 2025-04-04
+Per-PR attribution and contributor credits are published automatically on the corresponding GitHub release page; this file is the curated, human-readable summary.
+
+## [v0.6.0] - 2026-07-12
+### Breaking changes
+- `POST /dev/unload` is off by default; set `ALLOW_DEV_UNLOAD=true` to enable, otherwise returns 403. Shipped open in v0.5.0, now opt-in (#483).
+- `/debug/*` routes also set off by default, continuation of above; to avoid unintentional exposure of internals (stack traces, temp storage, CPU/mem/GPU); set `ENABLE_DEBUG_ENDPOINTS=true` to enable, otherwise 403's.
+- Removed lingering deprecated `/debug/session_pools`
+
+### Changed
+- Documented API stability: `/v1/*` is the stable surface; `/dev/*` and `/debug/*` are operational helpers that may change or move behind flags between minor releases.
+
+### Fixed
+- OpenAI voice aliases pointed at legacy v0.19 voicepacks that sound degraded on the v1.0 model. Added the proper v1.0 `bf_isabella` and repointed `nova` (`bf_v0isabella` -> `bf_isabella`), `alloy`, `ash`, `coral`, `echo` to their v1.0 voices. The `v0*` voices stay available by explicit name. (#479)
+- `/dev/captioned_speech` returned `timestamps: null` for non-English espeak voices (es/fr/it/hi/pt). Word timestamps are now derived from the model's own phoneme durations (`pred_dur`), so they match the audio exactly; falls back to the old behavior when word counts can't be reconciled. English path unchanged, ja/zh keep previous behavior. (#484)
+
+## [v0.5.0] - 2026-06-06
+### 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.
+- 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
+- GPU image variants for Blackwell / RTX 50-series (`:latest-cu128`, `:vX.Y.Z-cu128`, amd64 only) with PyTorch cu128 wheels (#443). Default `:latest` and new `:latest-cu126` alias stay on cu126 for Maxwell/Pascal compatibility.
+- Integration test suite (`api/tests/integration/`, opt-in `integration` marker) and a `tts-api-test-client` image that round-trips speech through faster-whisper against a live server. Run via `docker/docker-compose.test.yml`.
+- Web UI footer badge showing the server version from `/config`.
+
+### Breaking changes
+- `/v1/audio/voices` items in the `voices` array changed from plain strings to `{"id", "name"}` objects (#462) to match OpenWebUI/similar clients, and allow metadata in the response. Clients reading entries as strings will break; pass `?legacy=true` to restore the old item shape.
+ - Old: `{"voices": ["af_heart", ...]}`
+ - New: `{"voices": [{"id": "af_heart", "name": "af_heart"}, ...]}`
+
+### Changed
+- `api_version` now read from the `VERSION` file instead of hardcoded.
+- Removed the legacy `docker/{cpu,gpu}/Dockerfile`; the `.optimized` variants are the only build files now.
+- Docker images carry OCI metadata so GHCR pages render properly. Integration compose defaults to the published test-client image.
+- ROCm image defaults to `MIOPEN_FIND_MODE=2` so the on-disk kernel cache is reused instead of re-searched per process, and ships an opt-in warmup script at `docker/rocm/warmup_miopen.py` to pre-populate it. Recipe and benchmarks from @realugbun in #454.
+
+### Fixed
+- WAV responses drop junk size-field trailer that decoded as a click at chunk end. (#463)
+- ROCm MIOpen cache set to persist across compose restarts; switched bind mounts to named volumes at the path MIOpen writes to (prior mounts targeted an inaccessible location).
+- cpu/gpu composes set `DOWNLOAD_MODEL=true` for an idempotent model fetch on startup.
+- `VERSION` shipped into images so `/config` reports the real server version.
+- Silence trimming no longer treats full-scale-negative samples as silent (`int16` `abs()` overflow).
+- Fixed invalid escape sequences in the text-normalizer URL regex.
+- CI test job uses the CPU PyTorch build and excludes integration tests by default.
+
+## [v0.3.0] - 2026-05-15
+### Added
+- AMD GPU support via ROCm (`docker/rocm/` build, `rocm` extra in `pyproject.toml`). Also explored/proposed via @asheghi in #393.
+- `gpt-4o-mini-tts` model alias for OpenAI-compatible clients.
+- Reverse-proxy support for the Web UI (new `/config` endpoint exposing `UVICORN_ROOT_PATH`).
+- Configurable logging level via the `API_LOG_LEVEL` environment variable.
+- `INCLUDE_JAPANESE` Docker build flag for opt-in Japanese support.
+- Transcription accuracy test harness under `examples/assorted_checks/test_transcription/` (baselines, multilingual reports, long-form runner).
+- Override of `docker-bake.hcl` variables through GitHub Actions environment variables.
+
+### Changed
+- PyTorch bumped to 2.8.0 (x86_64: cu126, aarch64: cu129). x86_64 settled on cu126 to keep Maxwell/Pascal cards working, which drops native Blackwell (RTX 50-series) kernel support. Blackwell users need to override the torch index manually. See #443.
+- `kokoro` bumped to 0.9.4 and `misaki` to 0.9.4 (proposed by @jcheek in #371, superceded).
+- New optimized multi-stage Dockerfiles (`docker/{cpu,gpu}/Dockerfile.optimized`) become the default bake target. Reported image sizes: CPU 5.6 → 4.9 GB, GPU 14.8 → 9.9 GB.
+- Parallelized Docker bake targets per architecture for faster CI.
+- ROCBlas version pinned; ROCm docker-compose now builds locally.
+- CI/release workflow hardening: pinned BuildKit/runners, branch-tagged builds, manifest fixes, `workflow_dispatch` ref and tag-check race fixed, `latest` tag gated.
+
+### Fixed
+- OGG/Opus audio truncation where the final page was lost during `write_chunk` finalize.
+- Voice tensor loading hardened with `weights_only=True` (avoids unsafe pickle in `torch.load`).
+- Per-request voice-tensor memory leak resolved via caching (#453), with cache cleared on unload.
+- Custom phoneme handling made significantly more robust.
+- Firefox Web UI playback falls back gracefully when `audio/mpeg` MSE is unsupported; waveform rendering bugfix bundled in the same web rewrite.
+- CPU Docker builds: Rust now installed for `appuser` with proper `PATH` and longer `uv` timeouts.
+- `cmake` added to CI deps to unblock `pyopenjtalk` builds (proposed by @jcheek in #371; superceded).
+- `start-gpu.sh` uses `#!/usr/bin/env bash` for broader compatibility.
+- Apple Silicon: `test_initial_state()` no longer fails.
+
+## [v0.2.4] - 2025-06-18
### Added
- Apple Silicon (MPS) acceleration support for macOS users.
- Voice subtraction capability for creating unique voice effects.
- Windows PowerShell start scripts (`start-cpu.ps1`, `start-gpu.ps1`).
- Automatic model downloading integrated into all start scripts.
- Example Helm chart values for Azure AKS and Nvidia GPU Operator deployments.
+- Volume multiplier setting.
+- Chinese punctuation-based sentence splitting.
- `CONTRIBUTING.md` guidelines for developers.
### Changed
-- Version bump of underlying Kokoro and Misaki libraries
+- Version bump of underlying Kokoro and Misaki libraries.
- Default API port reverted to 8880.
-- Docker containers now run as a non-root user for enhanced security.
+- Docker containers now run as a non-root user.
- Improved text normalization for numbers, currency, and time formats.
+- Improved MP3 encoding and audio-pause handling.
- Updated and improved Helm chart configurations and documentation.
- Enhanced temporary file management with better error tracking.
- Web UI dependencies (Siriwave) are now served locally.
- Standardized environment variable handling across shell/PowerShell scripts.
+- Rust installed in Dockerfile for builds requiring it.
### Fixed
-- Corrected an issue preventing download links from being returned when `streaming=false`.
-- Resolved errors in Windows PowerShell scripts related to virtual environment activation order.
-- Addressed potential segfaults during inference.
-- Fixed various Helm chart issues related to health checks, ingress, and default values.
-- Corrected audio quality degradation caused by incorrect bitrate settings in some cases.
-- Ensured custom phonemes provided in input text are preserved.
-- Fixed a 'MediaSource' error affecting playback stability in the web player.
+- Download links no longer dropped when `streaming=false` and `return_download_link=true`.
+- Windows PowerShell start scripts fixed around virtual-environment activation order.
+- Potential segfaults during inference addressed.
+- Helm chart issues around health checks, ingress, and default values.
+- Audio-quality degradation from incorrect bitrate settings in some paths.
+- Custom phonemes provided in input text are now preserved end-to-end.
+- 'MediaSource' error affecting playback stability in the web player.
+- CRLF line endings in `custom_responses.py` converted to LF.
+- Money parsing and related tests.
+- Additional safety checks on captioned-speech generation.
+- Phoneme handling fixes.
### Removed
-- Obsolete GitHub Actions build workflow, build and publish now occurs on merge to `Release` branch
+- Obsolete GitHub Actions build workflow; build and publish now occurs on merge to `Release` branch.
+
+## [v0.2.3] - 2025-03-06
+### Added
+- Streaming word timestamps.
+- `.gitattributes` for consistent line endings.
+
+### Changed
+- Text normalization improvements.
+
+### Fixed
+- Audio-quality regression caused by lower-bitrate encoding.
+- Disabled uvicorn/FastAPI `--reload` to avoid pegging a CPU core.
+
+## [v0.2.2] - 2025-02-13
+### Added
+- Helm chart.
+- Settings-based override of the default `lang_code`.
+- Advanced normalization settings.
+
+### Fixed
+- Speech not engaging reliably on the CPU image fallback.
+- Audio quality bumped via adjusted compression settings.
+- Web UI format-selection bug.
+
+## [v0.2.1] - 2025-02-10
+### Added
+- Dummy `/v1/models` endpoint for OpenAI compatibility (#144).
+
+### Changed
+- Caption flow now streams audio with tempfile download at completion, removing duplicate captions (#139).
+
+### Fixed
+- Compatibility with the `espeak-loader` dependency on misaki (#127).
+- Build system and model-download issues.
## [v0.2.0post1] - 2025-02-07
- Fix: Building Kokoro from source with adjustments, to avoid CUDA lock
diff --git a/README.md b/README.md
index 60a30c40..e46a04b5 100644
--- a/README.md
+++ b/README.md
@@ -2,26 +2,32 @@
-# _`FastKoko`_
-[]()
-[]()
-[](https://huggingface.co/spaces/Remsky/Kokoro-TTS-Zero)
+# _`FastKoko`_
+[](./CHANGELOG.md) []()
+[]()
+
+[](https://github.com/hexgrad/kokoro)
+[](https://github.com/hexgrad/misaki)
+[](https://huggingface.co/hexgrad/Kokoro-82M/commit/9901c2b79161b6e898b7ea857ae5298f47b8b0d6)
+
+[](https://huggingface.co/spaces/Remsky/FastKoko) [](https://github.com/remsky?tab=packages&repo_name=Kokoro-FastAPI)
-[](https://github.com/hexgrad/kokoro)
-[](https://github.com/hexgrad/misaki)
-[](https://huggingface.co/hexgrad/Kokoro-82M/commit/9901c2b79161b6e898b7ea857ae5298f47b8b0d6)
Dockerized FastAPI wrapper for [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) text-to-speech model
-- Multi-language support (English, Japanese, Chinese, _Vietnamese soon_)
-- OpenAI-compatible Speech endpoint, NVIDIA GPU accelerated or CPU inference with PyTorch
-- ONNX support coming soon, see v0.1.5 and earlier for legacy ONNX support in the interim
-- Debug endpoints for monitoring system stats, integrated web UI on localhost:8880/web
-- Phoneme-based audio generation, phoneme generation
-- Per-word timestamped caption generation
-- Voice mixing with weighted combinations
+- OpenAI-compatible Speech endpoint, multi-language support
+ - English (US/GB), Spanish, French, Hindi, Italian, Japanese, Brazilian Portuguese, Mandarin Chinese
+- Per-word timestamped caption generation, voice mixing with weighted combinations
+- Phoneme endpoints: generate phonemes from text, or generate audio from phonemes
+- Prebuilt multiplatform images
+ - CPU and NVIDIA GPU (CUDA): linux/amd64 + linux/arm64
+ - AMD GPU (ROCm, experimental): linux/amd64 only
+- Apple Silicon (MPS) supported when running directly via UV (no image)
+
### Integration Guides
+[](https://deepwiki.com/remsky/Kokoro-FastAPI) [](https://codewiki.google/github.com/remsky/kokoro-fastapi)
+
[](https://github.com/remsky/Kokoro-FastAPI/wiki/Setup-Kubernetes) [](https://github.com/remsky/Kokoro-FastAPI/wiki/Integrations-DigitalOcean) [](https://github.com/remsky/Kokoro-FastAPI/wiki/Integrations-SillyTavern)
[](https://github.com/remsky/Kokoro-FastAPI/wiki/Integrations-OpenWebUi)
## Get Started
@@ -29,19 +35,27 @@ Dockerized FastAPI wrapper for [Kokoro-82M](https://huggingface.co/hexgrad/Kokor
Quickest Start (docker run)
+Pre-built multi-arch images with models baked in.
-Pre built images are available to run, with arm/multi-arch support, and baked in models
-Refer to the core/config.py file for a full list of variables which can be managed via the environment
+`:latest` is available, but please pin to a release tag for stable usage.
-```bash
-# the `latest` tag can be used, though it may have some unexpected bonus features which impact stability.
- Named versions should be pinned for your regular usage.
- Feedback/testing is always welcome
+| Your hardware | Image |
+|---|---|
+| No GPU (any laptop, VPS, CPU-only server) | `kokoro-fastapi-cpu:latest` |
+| Apple Silicon (M1/M2/M3) | `kokoro-fastapi-cpu:latest` in Docker, or `./start-gpu_mac.sh` natively for MPS |
+| NVIDIA GTX 9xx, 10xx, 20xx, 30xx, 40xx (x86_64) | `kokoro-fastapi-gpu:latest-cu126` or `kokoro-fastapi-gpu:latest` |
+| NVIDIA RTX 50-series / Blackwell (x86_64) | `kokoro-fastapi-gpu:latest-cu128` |
+| NVIDIA on arm64 (Jetson, GH200) | `kokoro-fastapi-gpu:latest` (ships cu129, no cu126 arm64 wheels upstream) |
+| AMD GPU | `kokoro-fastapi-rocm:latest` (experimental, x86_64 only) |
-docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest # CPU, or:
-docker run --gpus all -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-gpu:latest #NVIDIA GPU
+```bash
+docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest # CPU
+docker run --gpus all -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-gpu:latest # NVIDIA (x86_64 or arm64)
+docker run --gpus all -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-gpu:latest-cu128 # NVIDIA Blackwell / RTX 50-series
+docker run --device=/dev/kfd --device=/dev/dri -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-rocm:latest # AMD
```
+Configuration via environment variables, see `core/config.py`. The `:latest` and `:latest-cu126` tags resolve to the same multi-arch image.
@@ -56,14 +70,16 @@ docker run --gpus all -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-gpu:latest #NV
git clone https://github.com/remsky/Kokoro-FastAPI.git
cd Kokoro-FastAPI
- cd docker/gpu # For GPU support
- # or cd docker/cpu # For CPU support
+ cd docker/gpu # For NVIDIA GPU support
+ # or cd docker/cpu # For CPU support
+ # or cd docker/rocm # For AMD GPU (ROCm, experimental, amd64 only)
docker compose up --build
- # *Note for Apple Silicon (M1/M2) users:
- # The current GPU build relies on CUDA, which is not supported on Apple Silicon.
- # If you are on an M1/M2/M3 Mac, please use the `docker/cpu` setup.
- # MPS (Apple's GPU acceleration) support is planned but not yet available.
+ # *Note for Apple Silicon (M1/M2/M3) users:
+ # The Docker GPU image is CUDA-only and won't run on Apple Silicon. With Docker, use `docker/cpu`.
+ # For native MPS (Apple GPU) acceleration, run directly via UV with `./start-gpu_mac.sh`.
+
+ cd ../.. # back to repo root for the paths below
# Models will auto-download, but if needed you can manually download:
python docker/scripts/download_model.py --output api/src/models/v1_0
@@ -160,7 +176,7 @@ import requests
response = requests.get("http://localhost:8880/v1/audio/voices")
-voices = response.json()["voices"]
+voices = [v["id"] for v in response.json()["voices"]]
# Generate audio
response = requests.post(
@@ -198,7 +214,7 @@ Combine voices and generate audio:
```python
import requests
response = requests.get("http://localhost:8880/v1/audio/voices")
-voices = response.json()["voices"]
+voices = [v["id"] for v in response.json()["voices"]]
# Example 1: Simple voice combination (50%/50% mix)
response = requests.post(
@@ -335,10 +351,28 @@ Key Streaming Metrics:
## Processing Details
-Performance Benchmarks
+Performance & Benchmarks
+
+### Hardware variants
+
+```bash
+# GPU: Requires NVIDIA driver with CUDA 12.6+ support (~35x-100x realtime speed)
+cd docker/gpu
+docker compose up --build
-Benchmarking was performed on generation via the local API using text lengths up to feature-length books (~1.5 hours output), measuring processing time and realtime factor. Tests were run on:
-- Windows 11 Home w/ WSL2
+# CPU: PyTorch CPU inference
+cd docker/cpu
+docker compose up --build
+
+# AMD GPU: ROCm 6.4 (experimental, amd64 only)
+cd docker/rocm
+docker compose up --build
+```
+
+### Throughput
+
+Benchmarking was performed on generation via the local API using text lengths up to feature-length books (~1.5 hours output), measuring processing time and realtime factor. Tests were run on:
+- Windows 11 Home w/ WSL2
- NVIDIA 4060Ti 16gb GPU @ CUDA 12.1
- 11th Gen i7-11700 @ 2.5GHz
- 64gb RAM
@@ -353,21 +387,53 @@ Benchmarking was performed on generation via the local API using text lengths up
Key Performance Metrics:
- Realtime Speed: Ranges between 35x-100x (generation time to output audio length)
- Average Processing Rate: 137.67 tokens/second (cl100k_base)
-
-
-GPU Vs. CPU
-```bash
-# GPU: Requires NVIDIA GPU with CUDA 12.8 support (~35x-100x realtime speed)
-cd docker/gpu
-docker compose up --build
+### Model Unload / VRAM Reclaim
-# CPU: PyTorch CPU inference
-cd docker/cpu
-docker compose up --build
+`POST /dev/unload` frees the model from VRAM and reloads lazily on the next request. Reclaim scales with load (the activation pool, not just weights) but plateaus: chunks cap at 450 tokens. Long-form = ~30 paragraphs. Same setup as above.
-```
-*Note: Overall speed may have reduced somewhat with the structural changes to accommodate streaming. Looking into it*
+
+
+
+
+
+| Workload | Loaded | Floor | Reclaimed | Reload |
+| --- | --- | --- | --- | --- |
+| Short (6s audio) | 3.11 GB | 2.37 GB | 758 MiB | +4.9s |
+| Long-form (7.5m) | 3.98 GB | 2.37 GB | 1,656 MiB | +5.1s |
+
+Floor is host + CUDA context. Reproduce with `uv run --extra benchmarks assorted_checks/benchmarks/benchmark_model_unload.py` from `examples/`.
+
+### Transcription roundtrip (WER/CER)
+
+End-to-end roundtrip: synthesize with Kokoro, transcribe the result back with [`faster-whisper`](https://github.com/SYSTRAN/faster-whisper), compare to the source text. Scripts and data live under `examples/assorted_checks/test_transcription/`.
+
+**Long-form English** (full book, *A Journey to the Centre of the Earth*, Project Gutenberg, voice `af_heart`, `base.en` Whisper on CUDA float16, baseline captured on cu126 GPU build):
+
+| Run | Input chars | Audio length | Synth speedup | Transcribe speedup | WER |
+| --- | --- | --- | --- | --- | --- |
+| Short (~ch.7) | 64,996 | 66m 06s | 36.4x rt | 62.4x rt | **0.047** |
+| Full book | 502,766 | 507m 52s | 45.7x rt | 65.1x rt | **0.033** |
+
+See `examples/assorted_checks/test_transcription/BASELINE.md` for the full regression bands.
+
+**Per-language check** (single-sentence per voice, multilingual Whisper `small`. WER for Latin scripts, CER for ja/zh/hi):
+
+| Language | Voice | Metric | Score |
+| --- | --- | --- | --- |
+| English | `af_heart` | WER | 0.000 |
+| English (UK) | `bf_emma` | WER | 0.111 |
+| Spanish | `ef_dora` | WER | 0.000 |
+| French | `ff_siwis` | WER | 0.000 |
+| Italian | `if_sara` | WER | 0.000 |
+| Portuguese | `pf_dora` | WER | 0.000 |
+| Hindi | `hf_alpha` | CER | 0.059 |
+| Japanese | `jf_alpha` | CER | 0.000 |
+| Chinese | `zf_xiaobei` | CER | 0.143 |
+
+*Caveat: these are single short sentences, not a comprehensive per-language quality benchmark. They confirm each voice produces transcribable audio in its target language; deeper quality evaluation per language is open work.*
+
+To reproduce, see `examples/assorted_checks/test_transcription/README.md`.
@@ -500,17 +566,32 @@ except Exception as e:
See `examples/phoneme_examples/generate_phonemes.py` for a sample script.
+
+Inline Control Tokens
+
+Two tokens can be embedded in the `input` text and are parsed server-side (API, WebUI, or any client):
+
+- **Pause**: `[pause:1.5s]` inserts that much silence. Must be exactly this form (colon, trailing `s`, case-insensitive). `[pause=1.5]`, `[PAUSE 1.0]`, and SSML `` are not recognized and get read aloud.
+- **Pronunciation**: `[Worcester](/wˈʊstər/)` speaks the IPA between the slashes instead of the word. English only; use `/dev/phonemize` to find the IPA.
+
+```text
+The city of [Worcester](/wˈʊstər/) is easy. [pause:1s] See?
+```
+
+
Debug Endpoints
-Monitor system state and resource usage with these endpoints:
+Monitor system state and resource usage with these endpoints. The `/debug/*` routes expose host and process internals, so they are off by default; set `ENABLE_DEBUG_ENDPOINTS=true` to enable.
- `/debug/threads` - Get thread information and stack traces
- `/debug/storage` - Monitor temp file and output directory usage
- `/debug/system` - Get system information (CPU, memory, GPU)
-- `/debug/session_pools` - View ONNX session and CUDA stream status
+- `POST /dev/unload` - Release model from VRAM; reloads lazily on next request. Off by default; set `ALLOW_DEV_UNLOAD=true` to enable
Useful for debugging resource exhaustion or performance issues.
+
+Stability: the `/v1/*` OpenAI-compatible routes are the stable API. `/dev/*` and `/debug/*` are operational helpers, and may change or move behind flags between minor releases.
@@ -547,7 +628,7 @@ $env:API_LOG_LEVEL = 'WARNING'
Missing words & Missing some timestamps
-The api will automaticly do text normalization on input text which may incorrectly remove or change some phrases. This can be disabled by adding `"normalization_options":{"normalize": false}` to your request json:
+The api will automatically do text normalization on input text which may incorrectly remove or change some phrases. This can be disabled by adding `"normalization_options":{"normalize": false}` to your request json:
```python
import requests
@@ -571,30 +652,6 @@ for chunk in response.iter_content(chunk_size=1024):
pass
```
-
-
-
-Versioning & Development
-
-**Branching Strategy:**
-* **`release` branch:** Contains the latest stable build, recommended for production use. Docker images tagged with specific versions (e.g., `v0.3.0`) are built from this branch.
-* **`master` branch:** Used for active development. It may contain experimental features, ongoing changes, or fixes not yet in a stable release. Use this branch if you want the absolute latest code, but be aware it might be less stable. The `latest` Docker tag often points to builds from this branch.
-
-Note: This is a *development* focused project at its core.
-
-If you run into trouble, you may have to roll back a version on the release tags if something comes up, or build up from source and/or troubleshoot + submit a PR.
-
-Free and open source is a community effort, and there's only really so many hours in a day. If you'd like to support the work, feel free to open a PR, buy me a coffee, or report any bugs/features/etc you find during use.
-
-
-
-
-
-
@@ -642,7 +699,38 @@ Visit [NVIDIA Container Toolkit installation](https://docs.nvidia.com/datacenter
-## Model and License
+
+WAV duration reported as nonsense in some readers
+
+WAV responses ship with streaming-sentinel (`0xFFFFFFFF`) size fields in the header. Most readers (`soundfile`, `pydub`/ffmpeg, browsers, OS players) handle this fine. Python's stdlib `wave` does not, and reports a bogus duration. Use `soundfile.info(path).duration` or `ffprobe` for exact length.
+
+
+
+## Project
+
+
+Versioning & Development
+
+**Branching Strategy:**
+* **`release` branch:** Contains the latest stable build, recommended for production use. Docker images tagged with specific versions are built from this branch.
+* **`master` branch:** Used for active development. It may contain experimental features, ongoing changes, or fixes not yet in a stable release. Use this branch if you want the absolute latest code, but be aware it might be less stable. The `latest` Docker tag often points to builds from this branch.
+
+Note: This is a *development* focused project at its core.
+
+If you run into trouble, you may have to roll back a version on the release tags if something comes up, or build up from source and/or troubleshoot + submit a PR.
+
+Free and open source is a community effort, and there's only really so many hours in a day. If you'd like to support the work, feel free to open a PR, buy me a coffee, or report any bugs/features/etc you find during use.
+
+
+
+
+
+
+
Model
@@ -672,4 +760,4 @@ The full Apache 2.0 license text can be found at: https://www.apache.org/license
-Made with [contrib.rocks](https://contrib.rocks).
\ No newline at end of file
+Made with [contrib.rocks](https://contrib.rocks).
diff --git a/VERSION b/VERSION
index 72f9fa82..09a3acfa 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.2.4
\ No newline at end of file
+0.6.0
\ No newline at end of file
diff --git a/api/src/core/config.py b/api/src/core/config.py
index 87edce02..6bb54c1f 100644
--- a/api/src/core/config.py
+++ b/api/src/core/config.py
@@ -1,12 +1,28 @@
+from importlib.metadata import (
+ PackageNotFoundError,
+ version as _pkg_version,
+)
+from pathlib import Path
+
import torch
from pydantic_settings import BaseSettings
+def _read_version() -> str:
+ version_file = Path(__file__).resolve().parents[3] / "VERSION"
+ if version_file.exists():
+ return version_file.read_text().strip()
+ try:
+ return _pkg_version("kokoro-fastapi")
+ except PackageNotFoundError:
+ return "0.0.0"
+
+
class Settings(BaseSettings):
# API Settings
api_title: str = "Kokoro TTS API"
api_description: str = "API for text-to-speech generation using Kokoro"
- api_version: str = "1.0.0"
+ api_version: str = _read_version()
host: str = "0.0.0.0"
port: int = 8880
@@ -24,6 +40,10 @@ class Settings(BaseSettings):
allow_local_voice_saving: bool = (
False # Whether to allow saving combined voices locally
)
+ allow_dev_unload: bool = False # Whether to expose the POST /dev/unload endpoint
+ enable_debug_endpoints: bool = (
+ False # Whether to expose /debug/* host and process introspection routes
+ )
# Container absolute paths
model_dir: str = "/app/api/src/models" # Absolute path in container
diff --git a/api/src/core/openai_mappings.json b/api/src/core/openai_mappings.json
index 2821bd62..20597fb7 100644
--- a/api/src/core/openai_mappings.json
+++ b/api/src/core/openai_mappings.json
@@ -2,13 +2,14 @@
"models": {
"tts-1": "kokoro-v1_0",
"tts-1-hd": "kokoro-v1_0",
- "kokoro": "kokoro-v1_0"
+ "kokoro": "kokoro-v1_0",
+ "gpt-4o-mini-tts": "kokoro-v1_0"
},
"voices": {
- "alloy": "am_v0adam",
- "ash": "af_v0nicole",
- "coral": "bf_v0emma",
- "echo": "af_v0bella",
+ "alloy": "am_adam",
+ "ash": "af_nicole",
+ "coral": "bf_emma",
+ "echo": "af_bella",
"fable": "af_sarah",
"onyx": "bm_george",
"nova": "bf_isabella",
diff --git a/api/src/core/paths.py b/api/src/core/paths.py
index 771b70c3..d709f1c6 100644
--- a/api/src/core/paths.py
+++ b/api/src/core/paths.py
@@ -3,6 +3,7 @@
import io
import json
import os
+import time
from pathlib import Path
from typing import Any, AsyncIterator, Callable, Dict, List, Optional, Set
@@ -160,7 +161,7 @@ def filter_voice_files(name: str) -> bool:
async def load_voice_tensor(
- voice_path: str, device: str = "cpu", weights_only=False
+ voice_path: str, device: str = "cpu", weights_only=True
) -> torch.Tensor:
"""Load voice tensor from file.
@@ -329,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")
@@ -344,12 +355,13 @@ async def cleanup_temp_files() -> None:
await aiofiles.os.makedirs(settings.temp_file_dir, exist_ok=True)
return
+ now = time.time()
+ max_age = settings.max_temp_dir_age_hours * 3600
entries = await aiofiles.os.scandir(settings.temp_file_dir)
for entry in entries:
if entry.is_file():
stat = await aiofiles.os.stat(entry.path)
- max_age = stat.st_mtime + (settings.max_temp_dir_age_hours * 3600)
- if max_age < stat.st_mtime:
+ if now - stat.st_mtime > max_age:
try:
await aiofiles.os.remove(entry.path)
logger.info(f"Cleaned up old temp file: {entry.name}")
diff --git a/api/src/inference/kokoro_v1.py b/api/src/inference/kokoro_v1.py
index a627dbb3..81c76a90 100644
--- a/api/src/inference/kokoro_v1.py
+++ b/api/src/inference/kokoro_v1.py
@@ -14,6 +14,69 @@
from ..structures.schemas import WordTimestamp
from .base import AudioChunk, BaseModelBackend
+_ESPEAK_TS_SCALE = 2.0 / 80.0 # pred_dur unit -> seconds (matches KPipeline.join_timestamps)
+
+
+def _espeak_word_timestamps(graphemes, phonemes, pred_dur, g2p=None):
+ """Derive word timestamps for espeak-based (non-English) pipelines.
+
+ KPipeline attaches timed tokens only for English (misaki G2P) voices, but
+ the model predicts a duration for every phoneme in every language — the
+ audio is rendered from exactly those durations. pred_dur[0] is BOS and
+ pred_dur[1 + i] covers phonemes[i], so summing per-character durations and
+ splitting at the phoneme string's spaces yields exact word times.
+
+ Words espeak expands into several spoken words (numbers, some
+ abbreviations) are reconciled by re-phonemizing per word via `g2p`; if the
+ counts still disagree, returns None so the caller emits no timestamps and
+ clients can fall back to their own handling.
+ """
+ try:
+ if pred_dur is None or not phonemes or len(pred_dur) < len(phonemes) + 1:
+ return None
+ words = graphemes.split()
+ if not words:
+ return None
+ groups = [] # [start, end) seconds per space-separated phoneme group
+ t = float(pred_dur[0]) * _ESPEAK_TS_SCALE
+ start = None
+ for i, ch in enumerate(phonemes):
+ d = float(pred_dur[1 + i]) * _ESPEAK_TS_SCALE
+ if ch.isspace():
+ if start is not None:
+ groups.append((start, t))
+ start = None
+ elif start is None:
+ start = t
+ t += d
+ if start is not None:
+ groups.append((start, t))
+
+ if len(groups) != len(words):
+ if g2p is None:
+ return None
+ counts = []
+ for w in words:
+ ps = g2p(w)
+ if isinstance(ps, tuple):
+ ps = ps[0]
+ counts.append(max(len((ps or "").split()), 1))
+ if sum(counts) != len(groups):
+ return None
+ merged, gi = [], 0
+ for c in counts:
+ merged.append((groups[gi][0], groups[gi + c - 1][1]))
+ gi += c
+ groups = merged
+
+ return [
+ WordTimestamp(word=w, start_time=round(s, 3), end_time=round(e, 3))
+ for w, (s, e) in zip(words, groups)
+ ]
+ except Exception as e:
+ logger.warning(f"espeak timestamp mapping failed: {e}")
+ return None
+
class KokoroV1(BaseModelBackend):
"""Kokoro backend with controlled resource management."""
@@ -25,6 +88,24 @@ def __init__(self):
self._device = settings.get_device()
self._model: Optional[KModel] = None
self._pipelines: Dict[str, KPipeline] = {} # Store pipelines by lang_code
+ self._voice_cache: Dict[str, torch.Tensor] = {} # Cache voice tensors by path
+
+ async def _get_voice_tensor(self, voice_path: str) -> torch.Tensor:
+ """Load voice tensor with in-memory caching to avoid repeated file I/O.
+
+ Args:
+ voice_path: Path to the .pt voice file
+
+ Returns:
+ Voice tensor on the target device
+ """
+ cache_key = f"{voice_path}:{self._device}"
+ if cache_key not in self._voice_cache:
+ self._voice_cache[cache_key] = await paths.load_voice_tensor(
+ voice_path, device=self._device
+ )
+ logger.debug(f"Cached voice tensor from {voice_path}")
+ return self._voice_cache[cache_key]
async def load_model(self, path: str) -> None:
"""Load pre-baked model.
@@ -60,8 +141,8 @@ async def load_model(self, path: str) -> None:
else:
self._model = self._model.cpu()
- except FileNotFoundError as e:
- raise e
+ except FileNotFoundError:
+ raise
except Exception as e:
raise RuntimeError(f"Failed to load Kokoro model: {e}")
@@ -133,18 +214,17 @@ async def generate_from_tokens(
voice_path = voice
voice_name = os.path.splitext(os.path.basename(voice_path))[0]
- # Load voice tensor with proper device mapping
- voice_tensor = await paths.load_voice_tensor(
- voice_path, device=self._device
- )
- # Save back to a temporary file with proper device mapping
+ # Load voice tensor with caching to avoid repeated file I/O
+ voice_tensor = await self._get_voice_tensor(voice_path)
+ # Save to temp file only if needed (pipeline requires a file path)
import tempfile
temp_dir = tempfile.gettempdir()
temp_path = os.path.join(
temp_dir, f"temp_voice_{os.path.basename(voice_path)}"
)
- await paths.save_voice_tensor(voice_tensor, temp_path)
+ if not os.path.exists(temp_path):
+ await paths.save_voice_tensor(voice_tensor, temp_path)
voice_path = temp_path
# Use provided lang_code, settings voice code override, or first letter of voice name
@@ -232,18 +312,17 @@ async def generate(
voice_path = voice
voice_name = os.path.splitext(os.path.basename(voice_path))[0]
- # Load voice tensor with proper device mapping
- voice_tensor = await paths.load_voice_tensor(
- voice_path, device=self._device
- )
- # Save back to a temporary file with proper device mapping
+ # Load voice tensor with caching to avoid repeated file I/O
+ voice_tensor = await self._get_voice_tensor(voice_path)
+ # Save to temp file only if needed (pipeline requires a file path)
import tempfile
temp_dir = tempfile.gettempdir()
temp_path = os.path.join(
temp_dir, f"temp_voice_{os.path.basename(voice_path)}"
)
- await paths.save_voice_tensor(voice_tensor, temp_path)
+ if not os.path.exists(temp_path):
+ await paths.save_voice_tensor(voice_tensor, temp_path)
voice_path = temp_path
# Use provided lang_code, settings voice code override, or first letter of voice name
@@ -310,6 +389,29 @@ async def generate(
logger.error(
f"Failed to process timestamps for chunk: {e}"
)
+ elif (
+ return_timestamps
+ and result.phonemes
+ and type(getattr(pipeline, "g2p", None)).__name__
+ == "EspeakG2P"
+ ):
+ # espeak pipelines (es/fr/it/hi/pt) yield no timed
+ # tokens; derive word times from the model's own
+ # phoneme durations instead. Espeak-only: other
+ # non-English G2Ps (ja/zh) have no space-separated
+ # word groups to map.
+ pred_dur = getattr(result, "pred_dur", None)
+ if (
+ pred_dur is None
+ and getattr(result, "output", None) is not None
+ ):
+ pred_dur = getattr(result.output, "pred_dur", None)
+ word_timestamps = _espeak_word_timestamps(
+ result.graphemes,
+ result.phonemes,
+ pred_dur,
+ g2p=getattr(pipeline, "g2p", None),
+ )
yield AudioChunk(
result.audio.numpy(), word_timestamps=word_timestamps
@@ -355,6 +457,7 @@ def unload(self) -> None:
for pipeline in self._pipelines.values():
del pipeline
self._pipelines.clear()
+ self._voice_cache.clear()
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.synchronize()
diff --git a/api/src/inference/model_manager.py b/api/src/inference/model_manager.py
index eb817ecb..231f1f97 100644
--- a/api/src/inference/model_manager.py
+++ b/api/src/inference/model_manager.py
@@ -1,7 +1,9 @@
"""Kokoro V1 model management."""
+import asyncio
from typing import Optional
+import torch
from loguru import logger
from ..core import paths
@@ -26,6 +28,7 @@ def __init__(self, config: Optional[ModelConfig] = None):
self._config = config or model_config
self._backend: Optional[KokoroV1] = None # Explicitly type as KokoroV1
self._device: Optional[str] = None
+ self._lock = asyncio.Lock()
def _determine_device(self) -> str:
"""Determine device based on settings."""
@@ -98,6 +101,15 @@ async def initialize_with_warmup(self, voice_manager) -> tuple[str, str, int]:
except Exception as e:
raise RuntimeError(f"Warmup failed: {e}")
+ async def ensure_backend(self) -> None:
+ """Reload the backend if it was unloaded via /dev/unload."""
+ if self._backend:
+ return
+ async with self._lock:
+ if not self._backend:
+ await self.initialize()
+ await self.load_model(self._config.pytorch_kokoro_v1_file)
+
def get_backend(self) -> BaseModelBackend:
"""Get initialized backend.
@@ -136,8 +148,8 @@ async def generate(self, *args, **kwargs):
Raises:
RuntimeError: If generation fails
"""
- if not self._backend:
- raise RuntimeError("Backend not initialized")
+ await self.ensure_backend()
+ assert self._backend is not None # ensure_backend loaded it or raised
try:
async for chunk in self._backend.generate(*args, **kwargs):
@@ -153,6 +165,16 @@ def unload_all(self) -> None:
self._backend.unload()
self._backend = None
+ async def unload(self) -> None:
+ """Release model from GPU memory. Reloads automatically on next request."""
+ async with self._lock:
+ if self._backend is not None:
+ self._backend.unload()
+ self._backend = None
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ logger.info("Model unloaded from GPU memory")
+
@property
def current_backend(self) -> str:
"""Get current backend type."""
diff --git a/api/src/main.py b/api/src/main.py
index 11bd3e5b..89a7759b 100644
--- a/api/src/main.py
+++ b/api/src/main.py
@@ -135,7 +135,7 @@ async def lifespan(app: FastAPI):
# Include routers
app.include_router(openai_router, prefix="/v1")
app.include_router(dev_router) # Development endpoints
-app.include_router(debug_router) # Debug endpoints
+app.include_router(debug_router) # Debug endpoints (403 unless enabled)
if settings.enable_web_player:
app.include_router(web_router, prefix="/web") # Web player static files
diff --git a/api/src/routers/debug.py b/api/src/routers/debug.py
index 8acb9fd7..c3476765 100644
--- a/api/src/routers/debug.py
+++ b/api/src/routers/debug.py
@@ -1,10 +1,11 @@
import threading
-import time
from datetime import datetime
import psutil
import torch
-from fastapi import APIRouter
+from fastapi import APIRouter, Depends, HTTPException
+
+from ..core.config import settings
try:
import GPUtil
@@ -13,7 +14,16 @@
except ImportError:
GPU_AVAILABLE = False
-router = APIRouter(tags=["debug"])
+
+async def _require_debug_enabled():
+ if not settings.enable_debug_endpoints:
+ raise HTTPException(
+ status_code=403,
+ detail={"error": "Debug endpoints are disabled"},
+ )
+
+
+router = APIRouter(tags=["debug"], dependencies=[Depends(_require_debug_enabled)])
@router.get("/debug/threads")
@@ -149,61 +159,3 @@ async def get_system_info():
"network": network_info,
"gpu": gpu_info,
}
-
-
-@router.get("/debug/session_pools")
-async def get_session_pool_info():
- """Get information about ONNX session pools."""
- from ..inference.model_manager import get_manager
-
- manager = await get_manager()
- pools = manager._session_pools
- current_time = time.time()
-
- pool_info = {}
-
- # Get CPU pool info
- if "onnx_cpu" in pools:
- cpu_pool = pools["onnx_cpu"]
- pool_info["cpu"] = {
- "active_sessions": len(cpu_pool._sessions),
- "max_sessions": cpu_pool._max_size,
- "sessions": [
- {"model": path, "age_seconds": current_time - info.last_used}
- for path, info in cpu_pool._sessions.items()
- ],
- }
-
- # Get GPU pool info
- if "onnx_gpu" in pools:
- gpu_pool = pools["onnx_gpu"]
- pool_info["gpu"] = {
- "active_sessions": len(gpu_pool._sessions),
- "max_streams": gpu_pool._max_size,
- "available_streams": len(gpu_pool._available_streams),
- "sessions": [
- {
- "model": path,
- "age_seconds": current_time - info.last_used,
- "stream_id": info.stream_id,
- }
- for path, info in gpu_pool._sessions.items()
- ],
- }
-
- # Add GPU memory info if available
- if GPU_AVAILABLE:
- try:
- gpus = GPUtil.getGPUs()
- if gpus:
- gpu = gpus[0] # Assume first GPU
- pool_info["gpu"]["memory"] = {
- "total_mb": gpu.memoryTotal,
- "used_mb": gpu.memoryUsed,
- "free_mb": gpu.memoryFree,
- "percent_used": (gpu.memoryUsed / gpu.memoryTotal) * 100,
- }
- except Exception:
- pass
-
- return pool_info
diff --git a/api/src/routers/development.py b/api/src/routers/development.py
index 8c8ed7e1..22e354dc 100644
--- a/api/src/routers/development.py
+++ b/api/src/routers/development.py
@@ -226,7 +226,8 @@ async def dual_output():
# Add any chunks that may be in the acumulator into the return word_timestamps
if chunk_data.word_timestamps is not None:
chunk_data.word_timestamps = (
- timestamp_acumulator + chunk_data.word_timestamps
+ timestamp_acumulator
+ + chunk_data.word_timestamps
)
timestamp_acumulator = []
else:
@@ -411,3 +412,29 @@ async def single_output():
"type": "server_error",
},
)
+
+
+@router.post("/dev/unload")
+async def unload_model(
+ tts_service: TTSService = Depends(get_tts_service),
+):
+ """Release the model from GPU VRAM without stopping the container.
+
+ The model reloads automatically on the next inference request.
+ Useful for homelab deployments where GPU memory is shared across services.
+ """
+ if not settings.allow_dev_unload:
+ raise HTTPException(
+ status_code=403,
+ detail={"error": "The /dev/unload endpoint is disabled"},
+ )
+ try:
+ if tts_service.model_manager is None:
+ raise HTTPException(status_code=503, detail={"error": "Model manager not initialized"})
+ await tts_service.model_manager.unload()
+ return JSONResponse({"status": "unloaded"})
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error unloading model: {e}")
+ raise HTTPException(status_code=500, detail={"error": str(e)})
diff --git a/api/src/routers/openai_compatible.py b/api/src/routers/openai_compatible.py
index c3252217..c1292621 100644
--- a/api/src/routers/openai_compatible.py
+++ b/api/src/routers/openai_compatible.py
@@ -471,6 +471,12 @@ async def list_models():
"created": 1686935002,
"owned_by": "kokoro",
},
+ {
+ "id": "gpt-4o-mini-tts",
+ "object": "model",
+ "created": 1686935002,
+ "owned_by": "kokoro",
+ },
]
return {"object": "list", "data": models}
@@ -540,12 +546,20 @@ async def retrieve_model(model: str):
@router.get("/audio/voices")
-async def list_voices():
- """List all available voices for text-to-speech"""
+async def list_voices(legacy: bool = False):
+ """List all available voices for text-to-speech.
+
+ Returns `[{"id": ..., "name": ...}, ...]` by default so OpenAI-compatible
+ clients (Open WebUI in particular, which does `voice['id']` directly and
+ silently falls back to a hardcoded 6-voice list otherwise) can render the
+ full voice list. Pass `?legacy=true` for the pre-0.3.x plain-string shape.
+ """
try:
tts_service = await get_tts_service()
voices = await tts_service.list_voices()
- return {"voices": voices}
+ if legacy:
+ return {"voices": voices}
+ return {"voices": [{"id": v, "name": v} for v in voices]}
except Exception as e:
logger.error(f"Error listing voices: {str(e)}")
raise HTTPException(
diff --git a/api/src/routers/web_player.py b/api/src/routers/web_player.py
index cd2c5e45..61bf48e1 100644
--- a/api/src/routers/web_player.py
+++ b/api/src/routers/web_player.py
@@ -20,9 +20,9 @@ async def get_web_config():
"""Get web player configuration including UVICORN_ROOT_PATH."""
if not settings.enable_web_player:
raise HTTPException(status_code=404, detail="Web player is disabled")
-
+
root_path = os.environ.get("UVICORN_ROOT_PATH", "")
-
+
return {
"root_path": root_path,
"version": settings.api_version,
diff --git a/api/src/services/audio.py b/api/src/services/audio.py
index 6ae6d791..d8432d53 100644
--- a/api/src/services/audio.py
+++ b/api/src/services/audio.py
@@ -80,12 +80,12 @@ def find_first_last_non_silent(
non_silent_index_start, non_silent_index_end = None, None
for X in range(0, len(audio_data)):
- if abs(audio_data[X]) > amplitude_threshold:
+ if abs(int(audio_data[X])) > amplitude_threshold:
non_silent_index_start = X
break
for X in range(len(audio_data) - 1, -1, -1):
- if abs(audio_data[X]) > amplitude_threshold:
+ if abs(int(audio_data[X])) > amplitude_threshold:
non_silent_index_end = X
break
diff --git a/api/src/services/streaming_audio_writer.py b/api/src/services/streaming_audio_writer.py
index de9c84e3..a837b68a 100644
--- a/api/src/services/streaming_audio_writer.py
+++ b/api/src/services/streaming_audio_writer.py
@@ -34,16 +34,16 @@ def __init__(self, format: str, sample_rate: int, channels: int = 1):
self.output_buffer = BytesIO()
container_options = {}
# Try disabling Xing VBR header for MP3 to fix iOS timeline reading issues
- if self.format == 'mp3':
+ if self.format == "mp3":
# Disable Xing VBR header
- container_options = {'write_xing': '0'}
+ container_options = {"write_xing": "0"}
logger.debug("Disabling Xing VBR header for MP3 encoding.")
self.container = av.open(
self.output_buffer,
mode="w",
format=self.format if self.format != "aac" else "adts",
- options=container_options # Pass options here
+ options=container_options, # Pass options here
)
self.stream = self.container.add_stream(
codec_map[self.format],
@@ -51,10 +51,12 @@ def __init__(self, format: str, sample_rate: int, channels: int = 1):
layout="mono" if self.channels == 1 else "stereo",
)
# Set bit_rate only for codecs where it's applicable and useful
- if self.format in ['mp3', 'aac', 'opus']:
+ if self.format in ["mp3", "aac", "opus"]:
self.stream.bit_rate = 128000
else:
- raise ValueError(f"Unsupported format: {self.format}") # Use self.format here
+ raise ValueError(
+ f"Unsupported format: {self.format}"
+ ) # Use self.format here
def close(self):
if hasattr(self, "container"):
@@ -80,13 +82,21 @@ def write_chunk(
for packet in packets:
self.container.mux(packet)
- # Closing the container handles writing the trailer and finalizing the file.
- # No explicit flush method is available or needed here.
- logger.debug("Muxed final packets.")
+ # Close the container FIRST. this writes the final OGG page
+ # (or other format trailer) to the output buffer. For OGG/Opus,
+ # the last page of audio data is only written during close().
+ self.container.close()
+ logger.debug("Closed container, final page/trailer written.")
- # Get the final bytes from the buffer *before* closing it
+ # Now read the buffer which includes all trailing data
data = self.output_buffer.getvalue()
- self.close() # Close container and buffer
+ self.output_buffer.close()
+
+ if self.format == "wav":
+ # close()'s seek-and-patch lands ~78 bytes of size-field
+ # junk in the truncated buffer. Decoded as samples it's
+ # an audible click at chunk end. issue #463.
+ return b""
return data
if audio_data is None or len(audio_data) == 0:
diff --git a/api/src/services/text_processing/normalizer.py b/api/src/services/text_processing/normalizer.py
index 1b1c9f7e..13d76763 100644
--- a/api/src/services/text_processing/normalizer.py
+++ b/api/src/services/text_processing/normalizer.py
@@ -11,6 +11,7 @@
import inflect
from numpy import number
+
# from text_to_num import text2num
from torch import mul
@@ -135,20 +136,20 @@
}
SYMBOL_REPLACEMENTS = {
- '~': ' ',
- '@': ' at ',
- '#': ' number ',
- '$': ' dollar ',
- '%': ' percent ',
- '^': ' ',
- '&': ' and ',
- '*': ' ',
- '_': ' ',
- '|': ' ',
- '\\': ' ',
- '/': ' slash ',
- '=': ' equals ',
- '+': ' plus ',
+ "~": " ",
+ "@": " at ",
+ "#": " number ",
+ "$": " dollar ",
+ "%": " percent ",
+ "^": " ",
+ "&": " and ",
+ "*": " ",
+ "_": " ",
+ "|": " ",
+ "\\": " ",
+ "/": " slash ",
+ "=": " equals ",
+ "+": " plus ",
}
MONEY_UNITS = {"$": ("dollar", "cent"), "£": ("pound", "pence"), "€": ("euro", "cent")}
@@ -160,7 +161,7 @@
URL_PATTERN = re.compile(
r"(https?://|www\.|)+(localhost|[a-zA-Z0-9.-]+(\.(?:"
+ "|".join(VALID_TLDS)
- + "))+|[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})(:[0-9]+)?([/?][^\s]*)?",
+ + r"))+|[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})(:[0-9]+)?([/?][^\s]*)?",
re.IGNORECASE,
)
@@ -408,7 +409,7 @@ def handle_time(t: re.Match[str]) -> str:
def normalize_text(text: str, normalization_options: NormalizationOptions) -> str:
"""Normalize text for TTS processing"""
-
+
# Handle email addresses first if enabled
if normalization_options.email_normalization:
text = EMAIL_PATTERN.sub(handle_email, text)
@@ -455,9 +456,9 @@ def normalize_text(text: str, normalization_options: NormalizationOptions) -> st
# Handle special characters that might cause audio artifacts first
# Replace newlines with spaces (or pauses if needed)
- text = text.replace('\n', ' ')
- text = text.replace('\r', ' ')
-
+ text = text.replace("\n", " ")
+ text = text.replace("\r", " ")
+
# Handle titles and abbreviations
text = re.sub(r"\bD[Rr]\.(?= [A-Z])", "Doctor", text)
text = re.sub(r"\b(?:Mr\.|MR\.(?= [A-Z]))", "Mister", text)
diff --git a/api/src/services/text_processing/phonemizer.py b/api/src/services/text_processing/phonemizer.py
index dabf3284..d8e76b41 100644
--- a/api/src/services/text_processing/phonemizer.py
+++ b/api/src/services/text_processing/phonemizer.py
@@ -3,8 +3,8 @@
import phonemizer
-from .normalizer import normalize_text
from ...structures.schemas import NormalizationOptions
+from .normalizer import normalize_text
phonemizers = {}
@@ -95,13 +95,13 @@ def phonemize(text: str, language: str = "a") -> str:
Phonemized text
"""
global phonemizers
-
+
# Strip input text first to remove problematic leading/trailing spaces
text = text.strip()
-
+
if language not in phonemizers:
phonemizers[language] = create_phonemizer(language)
-
+
result = phonemizers[language].phonemize(text)
# Final strip to ensure no leading/trailing spaces in phonemes
return result.strip()
diff --git a/api/src/services/text_processing/text_processor.py b/api/src/services/text_processing/text_processor.py
index 483618f9..cce7a128 100644
--- a/api/src/services/text_processing/text_processor.py
+++ b/api/src/services/text_processing/text_processor.py
@@ -2,7 +2,7 @@
import re
import time
-from typing import AsyncGenerator, Dict, List, Tuple, Optional
+from typing import AsyncGenerator, Dict, List, Optional, Tuple
from loguru import logger
@@ -34,10 +34,10 @@ def process_text_chunk(
List of token IDs
"""
start_time = time.time()
-
+
# Strip input text to remove any leading/trailing spaces that could cause artifacts
text = text.strip()
-
+
if not text:
return []
@@ -140,7 +140,7 @@ async def smart_split(
normalization_options: NormalizationOptions = NormalizationOptions(),
) -> AsyncGenerator[Tuple[str, List[int], Optional[float]], None]:
"""Build optimal chunks targeting 300-400 tokens, never exceeding max_tokens.
-
+
Yields:
Tuple of (text_chunk, tokens, pause_duration_s).
If pause_duration_s is not None, it's a pause chunk with empty text/tokens.
@@ -161,7 +161,9 @@ async def smart_split(
part_idx += 1
# --- Process Text Part ---
- if text_part_raw and text_part_raw.strip(): # Only process if the part is not empty string
+ if (
+ text_part_raw and text_part_raw.strip()
+ ): # Only process if the part is not empty string
# Strip leading and trailing spaces to prevent pause tag splitting artifacts
text_part_raw = text_part_raw.strip()
@@ -171,8 +173,9 @@ async def smart_split(
if lang_code in ["a", "b", "en-us", "en-gb"]:
processed_text = CUSTOM_PHONEMES.split(processed_text)
for index in range(0, len(processed_text), 2):
- processed_text[index] = normalize_text(processed_text[index], normalization_options)
-
+ processed_text[index] = normalize_text(
+ processed_text[index], normalization_options
+ )
processed_text = "".join(processed_text).strip()
else:
@@ -316,7 +319,9 @@ async def smart_split(
yield "", [], duration # Yield pause chunk
except (ValueError, TypeError):
# This case should be rare if re.fullmatch passed, but handle anyway
- logger.warning(f"Could not parse valid-looking pause duration: {duration_str}")
+ logger.warning(
+ f"Could not parse valid-looking pause duration: {duration_str}"
+ )
# --- End of parts loop ---
total_time = time.time() - start_time
diff --git a/api/src/services/tts_service.py b/api/src/services/tts_service.py
index 46c2fb4c..2fe51a79 100644
--- a/api/src/services/tts_service.py
+++ b/api/src/services/tts_service.py
@@ -15,6 +15,7 @@
from ..core.config import settings
from ..inference.base import AudioChunk
from ..inference.kokoro_v1 import KokoroV1
+from ..inference.model_manager import ModelManager
from ..inference.model_manager import get_manager as get_model_manager
from ..inference.voice_manager import get_manager as get_voice_manager
from ..structures.schemas import NormalizationOptions
@@ -33,7 +34,7 @@ class TTSService:
def __init__(self, output_dir: str = None):
"""Initialize service."""
self.output_dir = output_dir
- self.model_manager = None
+ self.model_manager: Optional[ModelManager] = None
self._voice_manager = None
@classmethod
@@ -87,7 +88,7 @@ async def _process_chunk(
if not tokens and not chunk_text:
return
- # Get backend
+ await self.model_manager.ensure_backend()
backend = self.model_manager.get_backend()
# Generate audio using pre-warmed model
@@ -101,7 +102,7 @@ async def _process_chunk(
lang_code=lang_code,
return_timestamps=return_timestamps,
):
- chunk_data.audio*=volume_multiplier
+ chunk_data.audio *= volume_multiplier
# For streaming, convert to bytes
if output_format:
try:
@@ -134,7 +135,7 @@ async def _process_chunk(
speed=speed,
return_timestamps=return_timestamps,
)
-
+
if chunk_data.audio is None:
logger.error("Model generated None for audio chunk")
return
@@ -143,7 +144,7 @@ async def _process_chunk(
logger.error("Model generated empty audio chunk")
return
- chunk_data.audio*=volume_multiplier
+ chunk_data.audio *= volume_multiplier
# For streaming, convert to bytes
if output_format:
@@ -174,7 +175,7 @@ async def _load_voice_from_path(self, path: str, weight: float):
raise ValueError(f"Voice not found at path: {path}")
logger.debug(f"Loading voice tensor from path: {path}")
- return torch.load(path, map_location="cpu") * weight
+ return torch.load(path, map_location="cpu", weights_only=True) * weight
async def _get_voices_path(self, voice: str) -> Tuple[str, str]:
"""Get voice path, handling combined voices.
@@ -272,7 +273,7 @@ async def generate_audio_stream(
chunk_index = 0
current_offset = 0.0
try:
- # Get backend
+ await self.model_manager.ensure_backend()
backend = self.model_manager.get_backend()
# Get voice path, handling combined voices
@@ -295,17 +296,26 @@ async def generate_audio_stream(
# --- Handle Pause Chunk ---
try:
logger.debug(f"Generating {pause_duration_s}s silence chunk")
- silence_samples = int(pause_duration_s * 24000) # 24kHz sample rate
+ silence_samples = int(
+ pause_duration_s * 24000
+ ) # 24kHz sample rate
# Create proper silence as int16 zeros to avoid normalization artifacts
silence_audio = np.zeros(silence_samples, dtype=np.int16)
- pause_chunk = AudioChunk(audio=silence_audio, word_timestamps=[]) # Empty timestamps for silence
+ pause_chunk = AudioChunk(
+ audio=silence_audio, word_timestamps=[]
+ ) # Empty timestamps for silence
# Format and yield the silence chunk
if output_format:
formatted_pause_chunk = await AudioService.convert_audio(
- pause_chunk, output_format, writer, speed=speed, chunk_text="",
- is_last_chunk=False, trim_audio=False, normalizer=stream_normalizer,
-
+ pause_chunk,
+ output_format,
+ writer,
+ speed=speed,
+ chunk_text="",
+ is_last_chunk=False,
+ trim_audio=False,
+ normalizer=stream_normalizer,
)
if formatted_pause_chunk.output:
yield formatted_pause_chunk
@@ -323,7 +333,9 @@ async def generate_audio_stream(
logger.error(f"Failed to process pause chunk: {str(e)}")
continue
- elif tokens or chunk_text.strip(): # Process if there are tokens OR non-whitespace text
+ elif (
+ tokens or chunk_text.strip()
+ ): # Process if there are tokens OR non-whitespace text
# --- Handle Text Chunk ---
try:
# Process audio for chunk
@@ -349,14 +361,20 @@ async def generate_audio_stream(
# Update offset based on the actual duration of the generated audio chunk
chunk_duration = 0
- if chunk_data.audio is not None and len(chunk_data.audio) > 0:
+ if (
+ chunk_data.audio is not None
+ and len(chunk_data.audio) > 0
+ ):
chunk_duration = len(chunk_data.audio) / 24000
current_offset += chunk_duration
# Yield the processed chunk (either formatted or raw)
if chunk_data.output is not None:
yield chunk_data
- elif chunk_data.audio is not None and len(chunk_data.audio) > 0:
+ elif (
+ chunk_data.audio is not None
+ and len(chunk_data.audio) > 0
+ ):
yield chunk_data
else:
logger.warning(
@@ -465,7 +483,7 @@ async def generate_from_phonemes(
"""
start_time = time.time()
try:
- # Get backend and voice path
+ await self.model_manager.ensure_backend()
backend = self.model_manager.get_backend()
voice_name, voice_path = await self._get_voices_path(voice)
diff --git a/api/src/structures/schemas.py b/api/src/structures/schemas.py
index 0aeab7b7..4fd6ca0e 100644
--- a/api/src/structures/schemas.py
+++ b/api/src/structures/schemas.py
@@ -69,7 +69,7 @@ class NormalizationOptions(BaseModel):
)
replace_remaining_symbols: bool = Field(
default=True,
- description="Replaces the remaining symbols after normalization with their words"
+ description="Replaces the remaining symbols after normalization with their words",
)
@@ -114,8 +114,7 @@ class OpenAISpeechRequest(BaseModel):
description="Optional language code to use for text processing. If not provided, will use first letter of voice name.",
)
volume_multiplier: Optional[float] = Field(
- default = 1.0,
- description="A volume multiplier to multiply the output audio by."
+ default=1.0, description="A volume multiplier to multiply the output audio by."
)
normalization_options: Optional[NormalizationOptions] = Field(
default=NormalizationOptions(),
@@ -162,8 +161,7 @@ class CaptionedSpeechRequest(BaseModel):
description="Optional language code to use for text processing. If not provided, will use first letter of voice name.",
)
volume_multiplier: Optional[float] = Field(
- default = 1.0,
- description="A volume multiplier to multiply the output audio by."
+ default=1.0, description="A volume multiplier to multiply the output audio by."
)
normalization_options: Optional[NormalizationOptions] = Field(
default=NormalizationOptions(),
diff --git a/api/src/voices/v1_0/bf_isabella.pt b/api/src/voices/v1_0/bf_isabella.pt
new file mode 100644
index 00000000..c9e2d731
Binary files /dev/null and b/api/src/voices/v1_0/bf_isabella.pt differ
diff --git a/api/tests/integration/__init__.py b/api/tests/integration/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py
new file mode 100644
index 00000000..9872a4d2
--- /dev/null
+++ b/api/tests/integration/conftest.py
@@ -0,0 +1,96 @@
+"""Integration test fixtures.
+
+Designed to run inside the `tts-api-test-client` image against a networked
+Kokoro server. The fixtures only set up clients; they do NOT manage server
+lifecycle.
+
+Required env:
+ KOKORO_BASE_URL Base URL of a running Kokoro server. Inside compose
+ this is `http://server:8880`; for local non-Docker
+ runs, point at whatever you've got listening.
+ WHISPER_MODEL Filesystem path (or model name) for faster-whisper.
+ The test client image pre-bakes weights at
+ /opt/whisper/small and sets this for you.
+"""
+
+from __future__ import annotations
+
+import os
+import time
+from collections.abc import Iterator
+
+import httpx
+import pytest
+
+SERVER_READY_TIMEOUT = float(os.environ.get("KOKORO_SERVER_READY_TIMEOUT", "120"))
+HEALTH_POLL_INTERVAL = 1.0
+
+
+def _wait_for_health(url: str, timeout: float) -> None:
+ deadline = time.monotonic() + timeout
+ last_error: Exception | None = None
+ while time.monotonic() < deadline:
+ try:
+ r = httpx.get(f"{url}/health", timeout=2.0)
+ if r.status_code == 200:
+ return
+ except Exception as exc:
+ last_error = exc
+ time.sleep(HEALTH_POLL_INTERVAL)
+ raise RuntimeError(
+ f"Server at {url} did not become healthy within {timeout}s "
+ f"(last error: {last_error!r})"
+ )
+
+
+@pytest.fixture(scope="session")
+def server_url() -> Iterator[str]:
+ raw = os.environ.get("KOKORO_BASE_URL")
+ if not raw:
+ pytest.skip(
+ "KOKORO_BASE_URL not set. Run via `docker compose -f "
+ "docker-compose.test.yml up` or set the env var to a "
+ "running Kokoro server's base URL."
+ )
+ base = raw.rstrip("/")
+ if base.endswith("/v1"):
+ base = base[:-3]
+ _wait_for_health(base, SERVER_READY_TIMEOUT)
+ yield base
+
+
+@pytest.fixture(scope="session")
+def openai_client(server_url: str):
+ import openai
+
+ # Tight client-side timeout: healthy synth on a CPU runner is 1-3s.
+ # Anything past 30s means the server is wedged; fail fast rather than
+ # stall the whole sweep on one hung case.
+ return openai.OpenAI(
+ base_url=f"{server_url}/v1",
+ api_key="not-needed",
+ timeout=30,
+ )
+
+
+@pytest.fixture(scope="session")
+def whisper_model():
+ """Load faster-whisper once per session.
+
+ Inside the test-client image, WHISPER_MODEL points at a pre-baked
+ directory so this never touches the network. For source-tree runs
+ you can set it to a model name (e.g. `small`) and faster-whisper
+ will fetch from Hugging Face on first use.
+
+ WHISPER_DEVICE / WHISPER_COMPUTE override the defaults below. Default
+ is cpu/int8 inside the image; bumping to cuda/float16 is supported if
+ you mount in a GPU and a CUDA-enabled ctranslate2.
+ """
+ from faster_whisper import WhisperModel
+
+ model = os.environ.get("WHISPER_MODEL", "small")
+ device = os.environ.get("WHISPER_DEVICE", "cpu")
+ compute = os.environ.get(
+ "WHISPER_COMPUTE", "int8" if device == "cpu" else "float16"
+ )
+ return WhisperModel(model, device=device, compute_type=compute)
diff --git a/api/tests/integration/test_tts_roundtrip.py b/api/tests/integration/test_tts_roundtrip.py
new file mode 100644
index 00000000..7fbfa396
--- /dev/null
+++ b/api/tests/integration/test_tts_roundtrip.py
@@ -0,0 +1,165 @@
+"""End-to-end smoke test: synthesize each language and transcribe back.
+
+For each (voice, language) case we:
+ 1. Call the OpenAI-compatible /v1/audio/speech endpoint against a live server
+ 2. Require the response to be non-empty (catches Kokoro's silent-fail mode)
+ 3. Run faster-whisper over the audio and require the score under a
+ deliberately generous threshold
+
+This is a smoke test, not a quality bar. WER/CER thresholds are loose on
+purpose. The signal we want is "did the pipeline break for language X."
+Tighter thresholds belong in a separate manual evaluation harness.
+"""
+
+from __future__ import annotations
+
+import io
+import sys
+import time
+import unicodedata
+import wave
+from dataclasses import dataclass
+
+import pytest
+
+# Windows stdout defaults to cp1252; printing Devanagari/CJK reference strings
+# or transcripts raises UnicodeEncodeError and surfaces as a fake test failure.
+# Reconfigure once at module import so any -s output is safe.
+if hasattr(sys.stdout, "reconfigure"):
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
+
+
+pytestmark = pytest.mark.integration
+
+
+@dataclass(frozen=True)
+class Case:
+ voice: str
+ lang: str
+ text: str
+
+
+# One short sentence per language. Keep these stable. Whisper handles them
+# well in CPU/int8 with `small`, so a regression below the threshold means
+# something actually broke in Kokoro, not Whisper drift.
+CASES: list[Case] = [
+ Case("af_heart", "en", "The quick brown fox jumps over the lazy dog."),
+ Case("bf_emma", "en", "The rain in Spain falls mainly on the plain."),
+ Case("ef_dora", "es", "El sol brilla en el cielo azul."),
+ Case("ff_siwis", "fr", "Le soleil brille dans le ciel bleu."),
+ Case("if_sara", "it", "Il gatto dorme sul tappeto rosso."),
+ Case("pf_dora", "pt", "O gato dorme no tapete vermelho."),
+ Case("hf_alpha", "hi", "आज मौसम बहुत अच्छा है।"),
+ Case("jf_alpha", "ja", "今日はとても良い天気です。"),
+ Case("zf_xiaobei", "zh", "今天天气非常好。"),
+]
+
+# Hindi uses combining diacritics; CJK has no word boundaries. CER is the
+# fair metric for any of these. Everything else gets WER.
+CER_LANGS = {"hi", "ja", "zh"}
+
+WER_THRESHOLD = 0.25
+CER_THRESHOLD = 0.25
+MIN_AUDIO_BYTES = 1000
+
+
+def _strip_for_cer(s: str) -> str:
+ return "".join(
+ c for c in s if not c.isspace() and not unicodedata.category(c).startswith("P")
+ )
+
+
+def _score(lang: str, reference: str, hypothesis: str) -> float:
+ # jiwer ships only in the tts-api-test-client image, not the base test
+ # env. Import it lazily so the default `pytest -m "not integration"` run
+ # can still collect this module (the marker filter runs after import).
+ from jiwer import cer, wer
+ from jiwer.transforms import (
+ Compose,
+ ReduceToListOfListOfWords,
+ RemoveMultipleSpaces,
+ RemovePunctuation,
+ Strip,
+ ToLowerCase,
+ )
+
+ if lang in CER_LANGS:
+ return cer(_strip_for_cer(reference), _strip_for_cer(hypothesis))
+
+ word_normalize = Compose(
+ [
+ ToLowerCase(),
+ RemovePunctuation(),
+ RemoveMultipleSpaces(),
+ Strip(),
+ ReduceToListOfListOfWords(),
+ ]
+ )
+ return wer(
+ reference,
+ hypothesis,
+ reference_transform=word_normalize,
+ hypothesis_transform=word_normalize,
+ )
+
+
+def _wav_seconds(audio_bytes: bytes) -> float:
+ with wave.open(io.BytesIO(audio_bytes), "rb") as wf:
+ frames = wf.getnframes()
+ rate = wf.getframerate()
+ return frames / float(rate) if rate else 0.0
+
+
+@pytest.mark.parametrize(
+ "case",
+ CASES,
+ ids=lambda c: f"{c.voice}-{c.lang}",
+)
+def test_tts_roundtrip(case: Case, openai_client, whisper_model, tmp_path):
+ threshold = CER_THRESHOLD if case.lang in CER_LANGS else WER_THRESHOLD
+ metric = "CER" if case.lang in CER_LANGS else "WER"
+
+ t0 = time.perf_counter()
+ response = openai_client.audio.speech.create(
+ model="tts-1",
+ voice=case.voice,
+ input=case.text,
+ response_format="wav",
+ )
+ audio = response.content
+ synth_s = time.perf_counter() - t0
+
+ assert audio, f"empty response body for {case.voice}/{case.lang}"
+ assert len(audio) >= MIN_AUDIO_BYTES, (
+ f"suspiciously small WAV for {case.voice}/{case.lang}: "
+ f"{len(audio)} bytes (server likely silent-failed)"
+ )
+
+ audio_path = tmp_path / f"{case.voice}_{case.lang}.wav"
+ audio_path.write_bytes(audio)
+ duration_s = _wav_seconds(audio)
+ assert duration_s > 0.2, (
+ f"WAV header parsed but duration is only {duration_s:.3f}s "
+ f"for {case.voice}/{case.lang}"
+ )
+
+ t0 = time.perf_counter()
+ segments, _info = whisper_model.transcribe(
+ str(audio_path), beam_size=1, language=case.lang
+ )
+ transcript = " ".join(seg.text for seg in segments).strip()
+ transcribe_s = time.perf_counter() - t0
+
+ score = _score(case.lang, case.text, transcript)
+ print(
+ f"[{case.voice}/{case.lang}] "
+ f"synth={synth_s:.2f}s transcribe={transcribe_s:.2f}s "
+ f"{metric}={score:.3f} (<{threshold}) "
+ f"heard={transcript!r}"
+ )
+ assert score < threshold, (
+ f"{metric} {score:.3f} exceeded threshold {threshold} "
+ f"for {case.voice}/{case.lang}. "
+ f"Expected: {case.text!r}. Heard: {transcript!r}."
+ )
diff --git a/api/tests/integration/test_voices_endpoint.py b/api/tests/integration/test_voices_endpoint.py
new file mode 100644
index 00000000..65fe036d
--- /dev/null
+++ b/api/tests/integration/test_voices_endpoint.py
@@ -0,0 +1,53 @@
+"""Conformance for /v1/audio/voices shape + the OpenAI short-name mappings.
+
+Default shape is `[{"id": ..., "name": ...}, ...]` for common
+OpenAI-compatible clients (e.g. Open WebUI). `?legacy=true` preserves the
+plain-string shape.
+"""
+
+from __future__ import annotations
+
+import httpx
+import pytest
+
+pytestmark = pytest.mark.integration
+
+
+def test_voices_default_shape(server_url: str):
+ r = httpx.get(f"{server_url}/v1/audio/voices", timeout=10.0)
+ r.raise_for_status()
+ voices = r.json()["voices"]
+ assert voices, "voice list is empty"
+ assert all(isinstance(v, dict) for v in voices), (
+ "default shape must be list[dict] for Open WebUI compatibility"
+ )
+ assert all(set(v.keys()) >= {"id", "name"} for v in voices), (
+ "every entry needs id + name"
+ )
+ assert all(v["id"] == v["name"] for v in voices), (
+ "id and name should match for Kokoro voices"
+ )
+
+
+def test_voices_legacy_shape(server_url: str):
+ r = httpx.get(f"{server_url}/v1/audio/voices?legacy=true", timeout=10.0)
+ r.raise_for_status()
+ voices = r.json()["voices"]
+ assert voices, "legacy voice list is empty"
+ assert all(isinstance(v, str) for v in voices), (
+ "?legacy=true must return plain strings"
+ )
+
+
+def test_nova_mapping_resolves(openai_client):
+ """OpenAI short-names in openai_mappings.json must point at real voices."""
+ response = openai_client.audio.speech.create(
+ model="tts-1",
+ voice="nova",
+ input="Test.",
+ response_format="wav",
+ )
+ assert response.content, "nova short-name produced empty audio"
+ assert len(response.content) >= 1000, (
+ "nova returned suspiciously small WAV; mapping likely broken again"
+ )
diff --git a/api/tests/test_debug_endpoints.py b/api/tests/test_debug_endpoints.py
new file mode 100644
index 00000000..091017bc
--- /dev/null
+++ b/api/tests/test_debug_endpoints.py
@@ -0,0 +1,26 @@
+"""Tests for the /debug/* opt-in gate."""
+
+from unittest.mock import patch
+
+from fastapi.testclient import TestClient
+
+from api.src.core.config import settings
+from api.src.main import app
+
+client = TestClient(app)
+
+
+def test_debug_endpoints_403_when_disabled():
+ """Disabled by default: every /debug/* route returns 403."""
+ for path in ["/debug/threads", "/debug/storage", "/debug/system"]:
+ response = client.get(path)
+ assert response.status_code == 403
+ assert response.json()["detail"]["error"] == "Debug endpoints are disabled"
+
+
+def test_debug_endpoints_200_when_enabled():
+ with patch.object(settings, "enable_debug_endpoints", True):
+ response = client.get("/debug/threads")
+
+ assert response.status_code == 200
+ assert "total_threads" in response.json()
diff --git a/api/tests/test_kokoro_v1.py b/api/tests/test_kokoro_v1.py
index 29d83c5a..f43da344 100644
--- a/api/tests/test_kokoro_v1.py
+++ b/api/tests/test_kokoro_v1.py
@@ -1,3 +1,5 @@
+import tempfile
+from pathlib import Path
from unittest.mock import ANY, MagicMock, patch
import numpy as np
@@ -19,7 +21,7 @@ def test_initial_state(kokoro_backend):
assert kokoro_backend._model is None
assert kokoro_backend._pipelines == {} # Now using dict of pipelines
# Device should be set based on settings
- assert kokoro_backend.device in ["cuda", "cpu"]
+ assert kokoro_backend.device in ["cuda", "cpu", "mps"]
@patch("torch.cuda.is_available", return_value=True)
@@ -47,13 +49,6 @@ def test_clear_memory(mock_sync, mock_clear, kokoro_backend):
mock_sync.assert_called_once()
-@pytest.mark.asyncio
-async def test_load_model_validation(kokoro_backend):
- """Test model loading validation."""
- with pytest.raises(RuntimeError, match="Failed to load Kokoro model"):
- await kokoro_backend.load_model("nonexistent_model.pth")
-
-
def test_unload_with_pipelines(kokoro_backend):
"""Test model unloading with multiple pipelines."""
# Mock loaded state with multiple pipelines
@@ -61,6 +56,7 @@ def test_unload_with_pipelines(kokoro_backend):
pipeline_a = MagicMock()
pipeline_e = MagicMock()
kokoro_backend._pipelines = {"a": pipeline_a, "e": pipeline_e}
+ kokoro_backend._voice_cache = {"af_heart.pt:cpu": MagicMock()}
assert kokoro_backend.is_loaded
# Test unload
@@ -68,6 +64,7 @@ def test_unload_with_pipelines(kokoro_backend):
assert not kokoro_backend.is_loaded
assert kokoro_backend._model is None
assert kokoro_backend._pipelines == {} # All pipelines should be cleared
+ assert kokoro_backend._voice_cache == {} # Voice tensors should be released
@pytest.mark.asyncio
@@ -137,10 +134,8 @@ async def test_generate_uses_correct_pipeline(kokoro_backend):
with (
patch("api.src.core.paths.load_voice_tensor") as mock_load_voice,
patch("api.src.core.paths.save_voice_tensor"),
- patch("tempfile.gettempdir") as mock_tempdir,
):
mock_load_voice.return_value = torch.ones(1)
- mock_tempdir.return_value = "/tmp"
# Mock KPipeline
mock_pipeline = MagicMock()
@@ -150,16 +145,79 @@ async def test_generate_uses_correct_pipeline(kokoro_backend):
async for _ in kokoro_backend.generate("test", "ef_voice", lang_code="e"):
pass
- # Should create pipeline with Spanish lang_code
+ # Should create pipeline with Spanish lang_code and call it.
assert "e" in kokoro_backend._pipelines
- # Use ANY to match the temp file path since it's dynamic
mock_pipeline.assert_called_with(
"test",
- voice=ANY, # Don't check exact path since it's dynamic
+ voice=ANY,
speed=1.0,
model=kokoro_backend._model,
)
- # Verify the voice path is a temp file path
- call_args = mock_pipeline.call_args
- assert isinstance(call_args[1]["voice"], str)
- assert call_args[1]["voice"].startswith("/tmp/temp_voice_")
+ # Voice was staged to a temp file with the expected basename in
+ # the OS temp dir (cross-platform: don't string-match separators).
+ voice_arg = Path(mock_pipeline.call_args[1]["voice"])
+ assert voice_arg.name == "temp_voice_ef_voice"
+ assert voice_arg.parent == Path(tempfile.gettempdir())
+
+
+def test_espeak_word_timestamps_basic():
+ """Word times derived from pred_dur for an espeak (non-English) result."""
+ from api.src.inference.kokoro_v1 import _espeak_word_timestamps
+
+ # phonemes "ab cd": BOS=8, a=4, b=4, space=8, c=4, d=4, EOS=0
+ # scale is 2/80 s per unit -> BOS 0.2s, each char 0.1s, space 0.2s
+ pred_dur = torch.tensor([8, 4, 4, 8, 4, 4, 0])
+ ts = _espeak_word_timestamps("hola mundo", "ab cd", pred_dur)
+
+ assert [t.word for t in ts] == ["hola", "mundo"]
+ assert ts[0].start_time == pytest.approx(0.2)
+ assert ts[0].end_time == pytest.approx(0.4)
+ assert ts[1].start_time == pytest.approx(0.6)
+ assert ts[1].end_time == pytest.approx(0.8)
+
+
+def test_espeak_word_timestamps_expansion():
+ """Words espeak expands (numbers) are merged back via per-word g2p."""
+ from api.src.inference.kokoro_v1 import _espeak_word_timestamps
+
+ # Three text words but four phoneme groups ("1863" speaks as two words).
+ phonemes = "a bb cc d"
+ pred_dur = torch.tensor([0, 4] + [8, 4, 4] + [8, 4, 4] + [8, 4] + [0])
+
+ def g2p(word):
+ return ("bb cc" if word == "1863" else "x", None)
+
+ ts = _espeak_word_timestamps("en 1863 el", phonemes, pred_dur, g2p=g2p)
+
+ assert [t.word for t in ts] == ["en", "1863", "el"]
+ # "1863" spans both expanded groups.
+ assert ts[1].start_time == pytest.approx(0.3)
+ assert ts[1].end_time == pytest.approx(0.9)
+ # Whole-string timing stays monotonic and inside the clip.
+ assert ts[0].end_time <= ts[1].start_time <= ts[2].start_time
+
+
+def test_espeak_word_timestamps_unreconcilable():
+ """Group/word count mismatch that g2p can't explain yields None."""
+ from api.src.inference.kokoro_v1 import _espeak_word_timestamps
+
+ phonemes = "a b c"
+ pred_dur = torch.tensor([0, 4, 8, 4, 8, 4, 0])
+
+ # g2p says every word is a single group: 2 != 3 -> give up.
+ ts = _espeak_word_timestamps("uno dos", phonemes, pred_dur, g2p=lambda w: (w, None))
+ assert ts is None
+
+ # No g2p available -> also None.
+ assert _espeak_word_timestamps("uno dos", phonemes, pred_dur) is None
+
+
+def test_espeak_word_timestamps_degenerate_inputs():
+ """Missing durations or empty text return None instead of raising."""
+ from api.src.inference.kokoro_v1 import _espeak_word_timestamps
+
+ assert _espeak_word_timestamps("hola", "abc", None) is None
+ assert _espeak_word_timestamps("hola", "", torch.tensor([0, 0])) is None
+ assert _espeak_word_timestamps("", "abc", torch.tensor([0, 4, 4, 4, 0])) is None
+ # pred_dur too short for the phoneme string.
+ assert _espeak_word_timestamps("hola", "abcdef", torch.tensor([0, 4])) is None
diff --git a/api/tests/test_model_unload.py b/api/tests/test_model_unload.py
new file mode 100644
index 00000000..534bfb81
--- /dev/null
+++ b/api/tests/test_model_unload.py
@@ -0,0 +1,262 @@
+"""Tests for ModelManager.unload(), lazy reinit in generate(), and POST /dev/unload."""
+
+import asyncio
+from contextlib import contextmanager
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import numpy as np
+import pytest
+from fastapi.testclient import TestClient
+
+from api.src.core.config import settings
+from api.src.inference.base import AudioChunk
+from api.src.inference.model_manager import ModelManager
+from api.src.main import app
+from api.src.routers.development import get_tts_service
+from api.src.services.tts_service import TTSService
+
+client = TestClient(app)
+
+
+@contextmanager
+def override_tts_service(service):
+ """Override the get_tts_service FastAPI dependency for the duration of the block."""
+ async def _override():
+ return service
+
+ app.dependency_overrides[get_tts_service] = _override
+ try:
+ yield
+ finally:
+ app.dependency_overrides.pop(get_tts_service, None)
+
+
+@pytest.fixture(autouse=True)
+def _enable_dev_unload(monkeypatch):
+ """Enable the /dev/unload gate for the endpoint tests in this module."""
+ monkeypatch.setattr(settings, "allow_dev_unload", True)
+
+
+# ---------------------------------------------------------------------------
+# ModelManager unit tests
+# ---------------------------------------------------------------------------
+
+
+def test_manager_init_creates_lock():
+ manager = ModelManager()
+ assert isinstance(manager._lock, asyncio.Lock)
+
+
+@pytest.mark.asyncio
+async def test_unload_clears_backend():
+ manager = ModelManager()
+ mock_backend = MagicMock()
+ manager._backend = mock_backend
+
+ with patch("api.src.inference.model_manager.torch") as mock_torch:
+ mock_torch.cuda.is_available.return_value = False
+ await manager.unload()
+
+ mock_backend.unload.assert_called_once()
+ assert manager._backend is None
+
+
+@pytest.mark.asyncio
+async def test_unload_when_already_none_is_noop():
+ manager = ModelManager()
+ assert manager._backend is None
+
+ with patch("api.src.inference.model_manager.torch") as mock_torch:
+ mock_torch.cuda.is_available.return_value = False
+ await manager.unload() # must not raise
+
+ assert manager._backend is None
+
+
+@pytest.mark.asyncio
+async def test_unload_calls_cuda_empty_cache_when_available():
+ manager = ModelManager()
+ manager._backend = MagicMock()
+
+ with patch("api.src.inference.model_manager.torch") as mock_torch:
+ mock_torch.cuda.is_available.return_value = True
+ await manager.unload()
+
+ mock_torch.cuda.empty_cache.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_unload_skips_cuda_empty_cache_when_unavailable():
+ manager = ModelManager()
+ manager._backend = MagicMock()
+
+ with patch("api.src.inference.model_manager.torch") as mock_torch:
+ mock_torch.cuda.is_available.return_value = False
+ await manager.unload()
+
+ mock_torch.cuda.empty_cache.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_ensure_backend_serializes_concurrent_reloads():
+ """Concurrent callers when _backend is None should trigger only one load cycle."""
+ manager = ModelManager()
+ assert manager._backend is None
+
+ mock_backend = MagicMock()
+ init_count = 0
+ load_count = 0
+
+ async def fake_initialize():
+ nonlocal init_count
+ init_count += 1
+ await asyncio.sleep(0) # yield so other tasks can attempt entry
+ manager._backend = mock_backend
+
+ async def fake_load(path):
+ nonlocal load_count
+ load_count += 1
+
+ with (
+ patch.object(manager, "initialize", side_effect=fake_initialize),
+ patch.object(manager, "load_model", side_effect=fake_load),
+ ):
+ await asyncio.gather(*[manager.ensure_backend() for _ in range(5)])
+
+ assert init_count == 1
+ assert load_count == 1
+
+
+@pytest.mark.asyncio
+async def test_generate_lazy_reinit_when_backend_none():
+ """generate() initializes backend lazily when _backend is None."""
+ manager = ModelManager()
+ assert manager._backend is None
+
+ mock_backend = MagicMock()
+ audio_chunk = AudioChunk(np.zeros(10, dtype=np.float32))
+
+ async def fake_generate(*args, **kwargs):
+ yield audio_chunk
+
+ mock_backend.generate = fake_generate
+
+ async def fake_initialize():
+ manager._backend = mock_backend
+
+ with (
+ patch.object(manager, "initialize", side_effect=fake_initialize) as mock_init,
+ patch.object(manager, "load_model", new_callable=AsyncMock) as mock_load,
+ ):
+ chunks = []
+ async for chunk in manager.generate("hello", ("voice", "/path/voice.pt")):
+ chunks.append(chunk)
+
+ mock_init.assert_called_once()
+ mock_load.assert_called_once_with(manager._config.pytorch_kokoro_v1_file)
+ assert len(chunks) == 1
+ assert chunks[0] is audio_chunk
+
+
+@pytest.mark.asyncio
+async def test_generate_skips_reinit_when_backend_set():
+ """generate() does not call initialize/load_model when backend already exists."""
+ manager = ModelManager()
+ mock_backend = MagicMock()
+ audio_chunk = AudioChunk(np.zeros(10, dtype=np.float32))
+
+ async def fake_generate(*args, **kwargs):
+ yield audio_chunk
+
+ mock_backend.generate = fake_generate
+ manager._backend = mock_backend
+
+ with (
+ patch.object(manager, "initialize", new_callable=AsyncMock) as mock_init,
+ patch.object(manager, "load_model", new_callable=AsyncMock) as mock_load,
+ ):
+ chunks = []
+ async for chunk in manager.generate("hello", ("voice", "/path/voice.pt")):
+ chunks.append(chunk)
+
+ mock_init.assert_not_called()
+ mock_load.assert_not_called()
+ assert len(chunks) == 1
+
+
+# ---------------------------------------------------------------------------
+# POST /dev/unload endpoint tests
+# ---------------------------------------------------------------------------
+
+
+def _mock_service(manager=None):
+ """Build a TTSService-shaped mock with the given model_manager."""
+ service = MagicMock(spec=TTSService)
+ service.model_manager = manager
+ return service
+
+
+def test_unload_endpoint_returns_200():
+ mock_manager = AsyncMock()
+ mock_manager.unload = AsyncMock()
+ service = _mock_service(manager=mock_manager)
+
+ with override_tts_service(service):
+ response = client.post("/dev/unload")
+
+ assert response.status_code == 200
+ assert response.json() == {"status": "unloaded"}
+ mock_manager.unload.assert_called_once()
+
+
+def test_unload_endpoint_403_when_disabled(monkeypatch):
+ """Returns 403 without touching the model when the gate is off."""
+ monkeypatch.setattr(settings, "allow_dev_unload", False)
+ mock_manager = AsyncMock()
+ mock_manager.unload = AsyncMock()
+ service = _mock_service(manager=mock_manager)
+
+ with override_tts_service(service):
+ response = client.post("/dev/unload")
+
+ assert response.status_code == 403
+ mock_manager.unload.assert_not_called()
+
+
+def test_unload_endpoint_idempotent():
+ """Calling /dev/unload twice both succeed — unload is a no-op when already clear."""
+ mock_manager = AsyncMock()
+ mock_manager.unload = AsyncMock()
+ service = _mock_service(manager=mock_manager)
+
+ with override_tts_service(service):
+ r1 = client.post("/dev/unload")
+ r2 = client.post("/dev/unload")
+
+ assert r1.status_code == 200
+ assert r2.status_code == 200
+ assert mock_manager.unload.call_count == 2
+
+
+def test_unload_endpoint_503_when_manager_none():
+ """Returns 503 when model_manager has not been initialised on the service."""
+ service = _mock_service(manager=None)
+
+ with override_tts_service(service):
+ response = client.post("/dev/unload")
+
+ assert response.status_code == 503
+ assert response.json()["detail"]["error"] == "Model manager not initialized"
+
+
+def test_unload_endpoint_500_on_exception():
+ """Returns 500 when manager.unload() raises unexpectedly."""
+ mock_manager = AsyncMock()
+ mock_manager.unload = AsyncMock(side_effect=RuntimeError("GPU exploded"))
+ service = _mock_service(manager=mock_manager)
+
+ with override_tts_service(service):
+ response = client.post("/dev/unload")
+
+ assert response.status_code == 500
+ assert "GPU exploded" in response.json()["detail"]["error"]
diff --git a/api/tests/test_normalizer.py b/api/tests/test_normalizer.py
index 6b5a8bfb..35f72fc8 100644
--- a/api/tests/test_normalizer.py
+++ b/api/tests/test_normalizer.py
@@ -177,7 +177,8 @@ def test_money():
assert (
normalize_text(
- "Your shopping spree cost $674.03!", normalization_options=NormalizationOptions()
+ "Your shopping spree cost $674.03!",
+ normalization_options=NormalizationOptions(),
)
== "Your shopping spree cost six hundred and seventy-four dollars and three cents!"
)
@@ -323,11 +324,13 @@ def test_non_url_text():
== "It costs fifty dollars."
)
+
def test_remaining_symbol():
"""Test that remaining symbols are replaced"""
assert (
normalize_text(
- "I love buying products @ good store here & @ other store", normalization_options=NormalizationOptions()
+ "I love buying products @ good store here & @ other store",
+ normalization_options=NormalizationOptions(),
)
== "I love buying products at good store here and at other store"
)
diff --git a/api/tests/test_openai_endpoints.py b/api/tests/test_openai_endpoints.py
index d5c7efcb..f682c9f1 100644
--- a/api/tests/test_openai_endpoints.py
+++ b/api/tests/test_openai_endpoints.py
@@ -78,13 +78,12 @@ def test_list_models(mock_openai_mappings):
data = response.json()
assert data["object"] == "list"
assert isinstance(data["data"], list)
- assert len(data["data"]) == 3 # tts-1, tts-1-hd, and kokoro
-
# Verify all expected models are present
model_ids = [model["id"] for model in data["data"]]
assert "tts-1" in model_ids
assert "tts-1-hd" in model_ids
assert "kokoro" in model_ids
+ assert "gpt-4o-mini-tts" in model_ids
# Verify model format
for model in data["data"]:
@@ -403,8 +402,12 @@ def test_list_voices(mock_tts_service):
data = response.json()
assert "voices" in data
assert len(data["voices"]) == 2
- assert "voice1" in data["voices"]
- assert "voice2" in data["voices"]
+ assert {"id": "voice1", "name": "voice1"} in data["voices"]
+ assert {"id": "voice2", "name": "voice2"} in data["voices"]
+
+ legacy = client.get("/v1/audio/voices?legacy=true")
+ assert legacy.status_code == 200
+ assert legacy.json()["voices"] == ["voice1", "voice2"]
@patch("api.src.routers.openai_compatible.settings")
diff --git a/api/tests/test_paths.py b/api/tests/test_paths.py
index 715934e7..a87f8fd5 100644
--- a/api/tests/test_paths.py
+++ b/api/tests/test_paths.py
@@ -1,4 +1,5 @@
import os
+from pathlib import Path
from unittest.mock import patch
import pytest
@@ -19,7 +20,7 @@ async def test_find_file_exists():
with patch("aiofiles.os.path.exists") as mock_exists:
mock_exists.return_value = True
path = await _find_file("test.txt", ["/test/path"])
- assert path == "/test/path/test.txt"
+ assert Path(path) == Path("/test/path/test.txt")
@pytest.mark.asyncio
@@ -38,7 +39,7 @@ async def test_find_file_with_filter():
mock_exists.return_value = True
filter_fn = lambda p: p.endswith(".txt")
path = await _find_file("test.txt", ["/test/path"], filter_fn)
- assert path == "/test/path/test.txt"
+ assert Path(path) == Path("/test/path/test.txt")
@pytest.mark.asyncio
@@ -66,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:
diff --git a/api/tests/test_text_processor.py b/api/tests/test_text_processor.py
index 3fc8a87c..99e87b6e 100644
--- a/api/tests/test_text_processor.py
+++ b/api/tests/test_text_processor.py
@@ -44,6 +44,7 @@ def test_get_sentence_info():
assert count == len(tokens)
assert count > 0
+
@pytest.mark.asyncio
async def test_smart_split_short_text():
"""Test smart splitting with text under max tokens."""
@@ -56,6 +57,7 @@ async def test_smart_split_short_text():
assert isinstance(chunks[0][0], str)
assert isinstance(chunks[0][1], list)
+
@pytest.mark.asyncio
async def test_smart_custom_phenomes():
"""Test smart splitting with text under max tokens."""
@@ -66,18 +68,24 @@ async def test_smart_custom_phenomes():
# Should have 1 chunks: text
assert len(chunks) == 1
-
+
# First chunk: text
assert chunks[0][2] is None # No pause
- assert "This is a short test sentence. [Kokoro](/kˈOkəɹO/) has a feature called custom phenomes. This is made possible by [Misaki](/misˈɑki/), the custom phenomizer that [Kokoro](/kˈOkəɹO/) version one uses" in chunks[0][0]
+ assert (
+ "This is a short test sentence. [Kokoro](/kˈOkəɹO/) has a feature called custom phenomes. This is made possible by [Misaki](/misˈɑki/), the custom phenomizer that [Kokoro](/kˈOkəɹO/) version one uses"
+ in chunks[0][0]
+ )
assert len(chunks[0][1]) > 0
+
@pytest.mark.asyncio
async def test_smart_split_only_phenomes():
"""Test input that is entirely made of phenome annotations."""
text = "[Kokoro](/kˈOkəɹO/) [Misaki 1.2](/misˈɑki/) [Test](/tɛst/)"
chunks = []
- async for chunk_text, chunk_tokens, pause_duration in smart_split(text, max_tokens=10):
+ async for chunk_text, chunk_tokens, pause_duration in smart_split(
+ text, max_tokens=10
+ ):
chunks.append((chunk_text, chunk_tokens, pause_duration))
assert len(chunks) == 1
@@ -153,7 +161,7 @@ async def test_smart_split_chinese_short():
async def test_smart_split_chinese_long():
"""Test Chinese smart splitting with longer text."""
text = "。".join([f"测试句子 {i}" for i in range(20)])
-
+
chunks = []
async for chunk_text, chunk_tokens, _ in smart_split(text, lang_code="z"):
chunks.append((chunk_text, chunk_tokens))
@@ -169,7 +177,7 @@ async def test_smart_split_chinese_long():
async def test_smart_split_chinese_punctuation():
"""Test Chinese smart splitting with punctuation preservation."""
text = "第一句!第二问?第三句;第四句:第五句。"
-
+
chunks = []
async for chunk_text, _, _ in smart_split(text, lang_code="z"):
chunks.append(chunk_text)
@@ -182,52 +190,53 @@ async def test_smart_split_chinese_punctuation():
async def test_smart_split_with_pause():
"""Test smart splitting with pause tags."""
text = "Hello world [pause:2.5s] How are you?"
-
+
chunks = []
async for chunk_text, chunk_tokens, pause_duration in smart_split(text):
chunks.append((chunk_text, chunk_tokens, pause_duration))
-
+
# Should have 3 chunks: text, pause, text
assert len(chunks) == 3
-
+
# First chunk: text
assert chunks[0][2] is None # No pause
assert "Hello world" in chunks[0][0]
assert len(chunks[0][1]) > 0
-
+
# Second chunk: pause
assert chunks[1][2] == 2.5 # 2.5 second pause
assert chunks[1][0] == "" # Empty text
assert len(chunks[1][1]) == 0 # No tokens
-
+
# Third chunk: text
assert chunks[2][2] is None # No pause
assert "How are you?" in chunks[2][0]
assert len(chunks[2][1]) > 0
+
@pytest.mark.asyncio
async def test_smart_split_with_two_pause():
"""Test smart splitting with two pause tags."""
text = "[pause:0.5s][pause:1.67s]0.5"
-
+
chunks = []
async for chunk_text, chunk_tokens, pause_duration in smart_split(text):
chunks.append((chunk_text, chunk_tokens, pause_duration))
-
+
# Should have 3 chunks: pause, pause, text
assert len(chunks) == 3
-
+
# First chunk: pause
assert chunks[0][2] == 0.5 # 0.5 second pause
assert chunks[0][0] == "" # Empty text
assert len(chunks[0][1]) == 0
-
+
# Second chunk: pause
assert chunks[1][2] == 1.67 # 1.67 second pause
assert chunks[1][0] == "" # Empty text
assert len(chunks[1][1]) == 0 # No tokens
-
+
# Third chunk: text
assert chunks[2][2] is None # No pause
assert "zero point five" in chunks[2][0]
- assert len(chunks[2][1]) > 0
\ No newline at end of file
+ assert len(chunks[2][1]) > 0
diff --git a/api/tests/test_tts_service.py b/api/tests/test_tts_service.py
index 968c31f0..ca45a332 100644
--- a/api/tests/test_tts_service.py
+++ b/api/tests/test_tts_service.py
@@ -1,9 +1,10 @@
+import os
+from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import numpy as np
import pytest
import torch
-import os
from api.src.services.tts_service import TTSService
@@ -104,8 +105,8 @@ async def test_get_voice_path_combined():
name, path = await service._get_voices_path("voice1+voice2")
assert name == "voice1+voice2"
# Verify the path points to a temporary file with expected format
- assert path.startswith("/tmp/")
- assert "voice1+voice2" in path
+ assert Path(path).parent == Path("/tmp")
+ assert "voice1+voice2" in Path(path).name
assert path.endswith(".pt")
mock_save.assert_called_once()
diff --git a/assets/gpu_model_unload_longform.png b/assets/gpu_model_unload_longform.png
new file mode 100644
index 00000000..9fb4c673
Binary files /dev/null and b/assets/gpu_model_unload_longform.png differ
diff --git a/assets/gpu_model_unload_short.png b/assets/gpu_model_unload_short.png
new file mode 100644
index 00000000..e8f62c65
Binary files /dev/null and b/assets/gpu_model_unload_short.png differ
diff --git a/charts/kokoro-fastapi/Chart.yaml b/charts/kokoro-fastapi/Chart.yaml
index ed6f6753..5d8653c7 100644
--- a/charts/kokoro-fastapi/Chart.yaml
+++ b/charts/kokoro-fastapi/Chart.yaml
@@ -2,8 +2,8 @@ apiVersion: v2
name: kokoro-fastapi
description: A Helm chart for deploying the Kokoro FastAPI TTS service to Kubernetes
type: application
-version: 0.3.0
-appVersion: "0.3.0"
+version: 0.6.0
+appVersion: "0.6.0"
keywords:
- tts
diff --git a/docker-bake.hcl b/docker-bake.hcl
index 89174aec..70e49ba8 100644
--- a/docker-bake.hcl
+++ b/docker-bake.hcl
@@ -19,25 +19,72 @@ variable "DOWNLOAD_MODEL" {
default = "true"
}
-# Common settings shared between targets
+# Source-control revision + build timestamp, populated from CI env.
+# Left blank for local builds so the resulting labels/annotations stay empty
+# rather than carrying stale values.
+variable "REVISION" {
+ default = ""
+}
+
+variable "CREATED" {
+ default = ""
+}
+
+# OCI metadata applied to every image. `labels` lands in the image config
+# (visible via `docker inspect`); `annotations` lands on the pushed manifest
+# (which is what GHCR reads for per-arch package pages). Index-level
+# annotations for the multi-arch tag are added in release.yml at
+# `imagetools create` time, since bake here only produces per-arch manifests.
target "_common" {
context = "."
args = {
DEBIAN_FRONTEND = "noninteractive"
DOWNLOAD_MODEL = "${DOWNLOAD_MODEL}"
}
+ labels = {
+ "org.opencontainers.image.source" = "https://github.com/${OWNER}/Kokoro-FastAPI"
+ "org.opencontainers.image.url" = "https://github.com/${OWNER}/Kokoro-FastAPI"
+ "org.opencontainers.image.licenses" = "Apache-2.0"
+ "org.opencontainers.image.revision" = "${REVISION}"
+ "org.opencontainers.image.version" = "${VERSION}"
+ "org.opencontainers.image.created" = "${CREATED}"
+ }
+ annotations = [
+ "org.opencontainers.image.source=https://github.com/${OWNER}/Kokoro-FastAPI",
+ "org.opencontainers.image.url=https://github.com/${OWNER}/Kokoro-FastAPI",
+ "org.opencontainers.image.licenses=Apache-2.0",
+ "org.opencontainers.image.revision=${REVISION}",
+ "org.opencontainers.image.version=${VERSION}",
+ "org.opencontainers.image.created=${CREATED}",
+ ]
}
# Base settings for CPU builds
target "_cpu_base" {
inherits = ["_common"]
- dockerfile = "docker/cpu/Dockerfile"
+ dockerfile = "docker/cpu/Dockerfile.optimized"
+ labels = {
+ "org.opencontainers.image.title" = "Kokoro-FastAPI (CPU)"
+ "org.opencontainers.image.description" = "Kokoro TTS served via FastAPI. CPU build."
+ }
+ annotations = [
+ "org.opencontainers.image.title=Kokoro-FastAPI (CPU)",
+ "org.opencontainers.image.description=Kokoro TTS served via FastAPI. CPU build.",
+ ]
}
# Base settings for GPU builds
target "_gpu_base" {
inherits = ["_common"]
- dockerfile = "docker/gpu/Dockerfile"
+ dockerfile = "docker/gpu/Dockerfile.optimized"
+ labels = {
+ "org.opencontainers.image.title" = "Kokoro-FastAPI (GPU)"
+ "org.opencontainers.image.description" = "Kokoro TTS served via FastAPI. NVIDIA GPU build (CUDA 12.6 amd64 / CUDA 12.9 arm64; cu128 tag for Blackwell)."
+ }
+ annotations = [
+ "org.opencontainers.image.title=Kokoro-FastAPI (GPU)",
+ "org.opencontainers.image.description=Kokoro TTS served via FastAPI. NVIDIA GPU build (CUDA 12.6 amd64 / CUDA 12.9 arm64; cu128 tag for Blackwell).",
+ ]
}
# CPU target with multi-platform support
@@ -45,25 +92,27 @@ target "cpu" {
inherits = ["_cpu_base"]
platforms = ["linux/amd64", "linux/arm64"]
tags = [
- "${REGISTRY}/${OWNER}/${REPO}-cpu:${VERSION}",
- "${REGISTRY}/${OWNER}/${REPO}-cpu:latest"
+ "${REGISTRY}/${OWNER}/${REPO}-cpu:${VERSION}"
]
}
-# GPU target with multi-platform support
-target "gpu" {
- inherits = ["_gpu_base"]
- platforms = ["linux/amd64", "linux/arm64"]
- tags = [
- "${REGISTRY}/${OWNER}/${REPO}-gpu:${VERSION}",
- "${REGISTRY}/${OWNER}/${REPO}-gpu:latest"
- ]
+# GPU multi-platform: dispatches to per-arch targets so each gets its own CUDA_VERSION
+group "gpu" {
+ targets = ["gpu-amd64", "gpu-arm64"]
}
# Base settings for AMD ROCm builds
target "_rocm_base" {
inherits = ["_common"]
dockerfile = "docker/rocm/Dockerfile"
+ labels = {
+ "org.opencontainers.image.title" = "Kokoro-FastAPI (ROCm)"
+ "org.opencontainers.image.description" = "Kokoro TTS served via FastAPI. AMD ROCm build (amd64 only)."
+ }
+ annotations = [
+ "org.opencontainers.image.title=Kokoro-FastAPI (ROCm)",
+ "org.opencontainers.image.description=Kokoro TTS served via FastAPI. AMD ROCm build (amd64 only).",
+ ]
}
@@ -72,8 +121,7 @@ target "cpu-amd64" {
inherits = ["_cpu_base"]
platforms = ["linux/amd64"]
tags = [
- "${REGISTRY}/${OWNER}/${REPO}-cpu:${VERSION}-amd64",
- "${REGISTRY}/${OWNER}/${REPO}-cpu:latest-amd64"
+ "${REGISTRY}/${OWNER}/${REPO}-cpu:${VERSION}-amd64"
]
}
@@ -81,26 +129,48 @@ target "cpu-arm64" {
inherits = ["_cpu_base"]
platforms = ["linux/arm64"]
tags = [
- "${REGISTRY}/${OWNER}/${REPO}-cpu:${VERSION}-arm64",
- "${REGISTRY}/${OWNER}/${REPO}-cpu:latest-arm64"
+ "${REGISTRY}/${OWNER}/${REPO}-cpu:${VERSION}-arm64"
]
}
target "gpu-amd64" {
inherits = ["_gpu_base"]
platforms = ["linux/amd64"]
+ args = {
+ CUDA_VERSION = "12.6.3"
+ }
+ # Per-arch tag carries the wheel variant so it parallels gpu-cu128-amd64.
+ # The published manifest still resolves to :VERSION / :VERSION-cu126 via release.yml.
tags = [
- "${REGISTRY}/${OWNER}/${REPO}-gpu:${VERSION}-amd64",
- "${REGISTRY}/${OWNER}/${REPO}-gpu:latest-amd64"
+ "${REGISTRY}/${OWNER}/${REPO}-gpu:${VERSION}-cu126-amd64"
]
}
target "gpu-arm64" {
inherits = ["_gpu_base"]
platforms = ["linux/arm64"]
+ args = {
+ CUDA_VERSION = "12.9.1"
+ }
+ # aarch64 uses cu129 wheels (no cu126 aarch64 wheels exist on pytorch.org).
tags = [
- "${REGISTRY}/${OWNER}/${REPO}-gpu:${VERSION}-arm64",
- "${REGISTRY}/${OWNER}/${REPO}-gpu:latest-arm64"
+ "${REGISTRY}/${OWNER}/${REPO}-gpu:${VERSION}-cu129-arm64"
+ ]
+}
+
+# Blackwell / RTX 50-series variant: cu128 torch wheels (sm_120 kernels).
+# x86_64 only; published as a -cu128 suffixed tag on the existing -gpu package.
+target "gpu-cu128-amd64" {
+ inherits = ["_gpu_base"]
+ platforms = ["linux/amd64"]
+ args = {
+ # 12.8.x is the first CUDA toolkit with Blackwell (sm_120) support and is
+ # what the cu128 torch wheels are built against. Keep base + wheel aligned.
+ CUDA_VERSION = "12.8.1"
+ GPU_EXTRA = "gpu-cu128"
+ }
+ tags = [
+ "${REGISTRY}/${OWNER}/${REPO}-gpu:${VERSION}-cu128-amd64"
]
}
@@ -109,8 +179,7 @@ target "rocm-amd64" {
inherits = ["_rocm_base"]
platforms = ["linux/amd64"]
tags = [
- "${REGISTRY}/${OWNER}/${REPO}-rocm:${VERSION}-amd64",
- "${REGISTRY}/${OWNER}/${REPO}-rocm:latest-amd64"
+ "${REGISTRY}/${OWNER}/${REPO}-rocm:${VERSION}-amd64"
]
}
@@ -127,6 +196,16 @@ target "gpu-dev" {
tags = ["${REGISTRY}/${OWNER}/${REPO}-gpu:dev"]
}
+target "gpu-cu128-dev" {
+ inherits = ["_gpu_base"]
+ # No multi-platform for dev builds
+ args = {
+ CUDA_VERSION = "12.8.1"
+ GPU_EXTRA = "gpu-cu128"
+ }
+ tags = ["${REGISTRY}/${OWNER}/${REPO}-gpu:dev-cu128"]
+}
+
group "dev" {
targets = ["cpu-dev", "gpu-dev"]
}
@@ -137,7 +216,7 @@ group "cpu-all" {
}
group "gpu-all" {
- targets = ["gpu", "gpu-amd64", "gpu-arm64"]
+ targets = ["gpu-amd64", "gpu-arm64", "gpu-cu128-amd64"]
}
group "rocm-all" {
@@ -145,9 +224,9 @@ group "rocm-all" {
}
group "all" {
- targets = ["cpu", "gpu", "rocm"]
+ targets = ["cpu", "gpu-amd64", "gpu-arm64", "gpu-cu128-amd64", "rocm-amd64"]
}
group "individual-platforms" {
- targets = ["cpu-amd64", "cpu-arm64", "gpu-amd64", "gpu-arm64", "rocm-amd64"]
+ targets = ["cpu-amd64", "cpu-arm64", "gpu-amd64", "gpu-arm64", "gpu-cu128-amd64", "rocm-amd64"]
}
diff --git a/docker/cpu/Dockerfile b/docker/cpu/Dockerfile
deleted file mode 100644
index b004d7a0..00000000
--- a/docker/cpu/Dockerfile
+++ /dev/null
@@ -1,58 +0,0 @@
-FROM python:3.10-slim
-
-# Install dependencies and check espeak location
-# Rust is required to build sudachipy and pyopenjtalk-plus
-RUN apt-get update -y && \
- apt-get install -y espeak-ng espeak-ng-data git libsndfile1 curl ffmpeg g++ cmake && \
- apt-get clean && rm -rf /var/lib/apt/lists/* && \
- mkdir -p /usr/share/espeak-ng-data && \
- ln -s /usr/lib/*/espeak-ng-data/* /usr/share/espeak-ng-data/ && \
- curl -LsSf https://astral.sh/uv/install.sh | sh && \
- mv /root/.local/bin/uv /usr/local/bin/ && \
- mv /root/.local/bin/uvx /usr/local/bin/ && \
- useradd -m -u 1000 appuser && \
- mkdir -p /app/api/src/models/v1_0 && \
- chown -R appuser:appuser /app
-
-USER appuser
-WORKDIR /app
-
-# Install Rust for the non-root user so builds (e.g., sudachipy) succeed
-RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
-
-# Ensure Cargo and the Python venv are on PATH; extend HTTP timeouts for uv
-ENV PATH="/home/appuser/.cargo/bin:/app/.venv/bin:$PATH" \
- UV_HTTP_TIMEOUT=120 \
- UV_HTTP_RETRIES=3
-
-# Copy dependency files
-COPY --chown=appuser:appuser pyproject.toml ./pyproject.toml
-
-# Install dependencies with CPU extras
-RUN uv venv --python 3.10 && \
- uv sync --extra cpu --no-cache
-
-# Copy project files including models
-COPY --chown=appuser:appuser api ./api
-COPY --chown=appuser:appuser web ./web
-COPY --chown=appuser:appuser docker/scripts/ ./
-RUN chmod +x ./entrypoint.sh
-
-# Set environment variables
-ENV PYTHONUNBUFFERED=1 \
- PYTHONPATH=/app:/app/api \
- UV_LINK_MODE=copy \
- USE_GPU=false \
- PHONEMIZER_ESPEAK_PATH=/usr/bin \
- PHONEMIZER_ESPEAK_DATA=/usr/share/espeak-ng-data \
- ESPEAK_DATA_PATH=/usr/share/espeak-ng-data \
- DEVICE="cpu"
-
-ENV DOWNLOAD_MODEL=true
-# Download model if enabled
-RUN if [ "$DOWNLOAD_MODEL" = "true" ]; then \
- python download_model.py --output api/src/models/v1_0; \
- fi
-
-# Run FastAPI server through entrypoint.sh
-CMD ["./entrypoint.sh"]
diff --git a/docker/cpu/Dockerfile.optimized b/docker/cpu/Dockerfile.optimized
new file mode 100644
index 00000000..6791eefe
--- /dev/null
+++ b/docker/cpu/Dockerfile.optimized
@@ -0,0 +1,85 @@
+# Stage 1: Builder
+FROM python:3.10 AS builder
+
+# Install build dependencies
+RUN apt-get update -y && \
+ apt-get install -y git curl g++ cmake make && \
+ curl -LsSf https://astral.sh/uv/install.sh | sh && \
+ mv /root/.local/bin/uv /usr/local/bin/ && \
+ mv /root/.local/bin/uvx /usr/local/bin/
+
+# Install Rust for building sudachipy and pyopenjtalk
+RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
+ENV PATH="/root/.cargo/bin:$PATH"
+
+WORKDIR /app
+
+# Copy dependency files
+COPY pyproject.toml ./pyproject.toml
+
+# Install dependencies with CPU extras
+ENV UV_HTTP_TIMEOUT=120 UV_HTTP_RETRIES=3
+
+RUN uv venv --python 3.10 && \
+ uv sync --extra cpu --no-cache --no-install-project
+
+# Stage 2: Runtime - slim image
+FROM python:3.10-slim
+
+# Install runtime dependencies + uv (needed by entrypoint.sh)
+RUN apt-get update -y && \
+ apt-get install -y espeak-ng espeak-ng-data libsndfile1 ffmpeg curl && \
+ curl -LsSf https://astral.sh/uv/install.sh | sh && \
+ mv /root/.local/bin/uv /usr/local/bin/ && \
+ mv /root/.local/bin/uvx /usr/local/bin/ && \
+ apt-get clean && rm -rf /var/lib/apt/lists/* && \
+ mkdir -p /usr/share/espeak-ng-data && \
+ ln -s /usr/lib/*/espeak-ng-data/* /usr/share/espeak-ng-data/ && \
+ useradd -m -u 1000 appuser && \
+ mkdir -p /app/api/src/models/v1_0 && \
+ chown -R appuser:appuser /app
+
+WORKDIR /app
+
+# Copy virtual environment from builder
+COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv
+
+# Download model in runtime stage so download_model.py is present for runtime re-downloads via entrypoint
+COPY --chown=appuser:appuser docker/scripts/download_model.py ./download_model.py
+ARG DOWNLOAD_MODEL=true
+RUN if [ "$DOWNLOAD_MODEL" = "true" ]; then \
+ /app/.venv/bin/python download_model.py --output api/src/models/v1_0 && \
+ chown -R appuser:appuser /app/api/src/models; \
+ fi
+
+# Japanese support requires the UniDic dictionary (~526MB on disk) for fugashi/MeCab.
+# Enabled by default; set --build-arg INCLUDE_JAPANESE=false to skip and shave the image.
+# Keep true. Setting false silently breaks ja generation.
+ARG INCLUDE_JAPANESE=true
+RUN if [ "$INCLUDE_JAPANESE" = "true" ]; then \
+ /app/.venv/bin/python -m unidic download && \
+ chown -R appuser:appuser /app/.venv/lib/python*/site-packages/unidic; \
+ fi
+
+# Copy project files
+COPY --chown=appuser:appuser api ./api
+COPY --chown=appuser:appuser web ./web
+COPY --chown=appuser:appuser VERSION ./VERSION
+COPY --chown=appuser:appuser docker/scripts/entrypoint.sh ./entrypoint.sh
+RUN chmod +x ./entrypoint.sh
+
+USER appuser
+
+# Set environment variables
+ENV PYTHONUNBUFFERED=1 \
+ PYTHONPATH=/app:/app/api \
+ PATH="/app/.venv/bin:$PATH" \
+ UV_LINK_MODE=copy \
+ USE_GPU=false \
+ PHONEMIZER_ESPEAK_PATH=/usr/bin \
+ PHONEMIZER_ESPEAK_DATA=/usr/share/espeak-ng-data \
+ ESPEAK_DATA_PATH=/usr/share/espeak-ng-data \
+ DEVICE="cpu"
+
+# Run FastAPI server
+CMD ["./entrypoint.sh"]
\ No newline at end of file
diff --git a/docker/cpu/docker-compose.yml b/docker/cpu/docker-compose.yml
index 7cb9141c..3013ca7b 100644
--- a/docker/cpu/docker-compose.yml
+++ b/docker/cpu/docker-compose.yml
@@ -1,9 +1,10 @@
name: kokoro-fastapi-cpu
services:
kokoro-tts:
+ # image: ghcr.io/remsky/kokoro-fastapi-cpu:v${VERSION}
build:
context: ../..
- dockerfile: docker/cpu/Dockerfile
+ dockerfile: docker/cpu/Dockerfile.optimized
volumes:
- ../../api:/app/api
ports:
@@ -18,7 +19,10 @@ services:
- ONNX_MEMORY_PATTERN=true
- ONNX_ARENA_EXTEND_STRATEGY=kNextPowerOfTwo
- API_LOG_LEVEL=DEBUG
-
+ - DOWNLOAD_MODEL=true
+ # - ALLOW_DEV_UNLOAD=true
+ # - ENABLE_DEBUG_ENDPOINTS=true
+
# # Gradio UI service [Comment out everything below if you don't need it]
# gradio-ui:
# image: ghcr.io/remsky/kokoro-fastapi-ui:v${VERSION}
diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml
new file mode 100644
index 00000000..58669ca2
--- /dev/null
+++ b/docker/docker-compose.test.yml
@@ -0,0 +1,52 @@
+name: kokoro-fastapi-test
+#
+# Test orchestration. Brings up the Kokoro server and the Whisper-equipped
+# TTS API test client, runs `pytest -m integration` against the live HTTP
+# API, and exits with the client container's status code.
+#
+# Run from the repo root:
+# docker compose -f docker/docker-compose.test.yml up --build \
+# --abort-on-container-exit --exit-code-from test-client
+#
+# Against a published server image (e.g. release verification):
+# SERVER_IMAGE=ghcr.io/remsky/kokoro-fastapi-cpu:v0.4.0 \
+# docker compose -f docker/docker-compose.test.yml up \
+# --abort-on-container-exit --exit-code-from test-client
+#
+# Against a published test-client image (skips local Whisper build):
+# TEST_CLIENT_IMAGE=ghcr.io/remsky/tts-api-test-client:v1 \
+# docker compose -f docker/docker-compose.test.yml up \
+# --abort-on-container-exit --exit-code-from test-client
+#
+services:
+ server:
+ image: ${SERVER_IMAGE:-kokoro-fastapi-test-server:local}
+ build:
+ context: ..
+ dockerfile: ${SERVER_DOCKERFILE:-docker/cpu/Dockerfile.optimized}
+ ports:
+ - "8880:8880"
+ environment:
+ - API_LOG_LEVEL=INFO
+ - PYTHONPATH=/app:/app/api
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - 'python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen(''http://localhost:8880/health'',timeout=2).status==200 else 1)"'
+ interval: 5s
+ timeout: 5s
+ retries: 60
+ start_period: 30s
+
+ test-client:
+ image: ${TEST_CLIENT_IMAGE:-ghcr.io/remsky/tts-api-test-client:latest}
+ build:
+ context: ./test-client
+ depends_on:
+ server:
+ condition: service_healthy
+ volumes:
+ - ../api/tests/integration:/tests/integration:ro
+ environment:
+ KOKORO_BASE_URL: http://server:8880
+ WHISPER_MODEL: /opt/whisper/small
diff --git a/docker/gpu/Dockerfile b/docker/gpu/Dockerfile
deleted file mode 100644
index 9083fa23..00000000
--- a/docker/gpu/Dockerfile
+++ /dev/null
@@ -1,51 +0,0 @@
-FROM --platform=$BUILDPLATFORM nvcr.io/nvidia/cuda:12.9.1-cudnn-devel-ubuntu24.04
-
-# Install Python and other dependencies
-RUN apt-get update -y && \
- apt-get install -y python3.10 python3-venv espeak-ng espeak-ng-data git libsndfile1 curl ffmpeg g++ cmake && \
- apt-get clean && rm -rf /var/lib/apt/lists/* && \
- mkdir -p /usr/share/espeak-ng-data && \
- ln -s /usr/lib/*/espeak-ng-data/* /usr/share/espeak-ng-data/ && \
- curl -LsSf https://astral.sh/uv/install.sh | sh && \
- mv /root/.local/bin/uv /usr/local/bin/ && \
- mv /root/.local/bin/uvx /usr/local/bin/ && \
- useradd -m -u 1001 appuser && \
- mkdir -p /app/api/src/models/v1_0 && \
- chown -R appuser:appuser /app
-
-USER appuser
-WORKDIR /app
-
-# Copy dependency files
-COPY --chown=appuser:appuser pyproject.toml ./pyproject.toml
-
-# Install dependencies with GPU extras
-RUN uv venv --python 3.10 && \
- uv sync --extra gpu --no-cache
-
-# Copy project files including models
-COPY --chown=appuser:appuser api ./api
-COPY --chown=appuser:appuser web ./web
-COPY --chown=appuser:appuser docker/scripts/ ./
-RUN chmod +x ./entrypoint.sh
-
-
-# Set all environment variables in one go
-ENV PATH="/app/.venv/bin:$PATH" \
- PYTHONUNBUFFERED=1 \
- PYTHONPATH=/app:/app/api \
- UV_LINK_MODE=copy \
- USE_GPU=true \
- PHONEMIZER_ESPEAK_PATH=/usr/bin \
- PHONEMIZER_ESPEAK_DATA=/usr/share/espeak-ng-data \
- ESPEAK_DATA_PATH=/usr/share/espeak-ng-data \
- DEVICE="gpu"
-
-ENV DOWNLOAD_MODEL=true
-# Download model if enabled
-RUN if [ "$DOWNLOAD_MODEL" = "true" ]; then \
- python download_model.py --output api/src/models/v1_0; \
- fi
-
-# Run FastAPI server through entrypoint.sh
-CMD ["./entrypoint.sh"]
diff --git a/docker/gpu/Dockerfile.optimized b/docker/gpu/Dockerfile.optimized
new file mode 100644
index 00000000..cd45c7ed
--- /dev/null
+++ b/docker/gpu/Dockerfile.optimized
@@ -0,0 +1,98 @@
+# CUDA_VERSION is overridden per-arch in docker-bake.hcl (amd64=12.6.3, arm64=12.9.1).
+# The arm64 bump is required because pytorch.org/whl/cu126 has no aarch64 wheels.
+ARG CUDA_VERSION=12.6.3
+
+# Stage 1: Builder - Use devel image for compilation
+FROM --platform=$BUILDPLATFORM nvcr.io/nvidia/cuda:${CUDA_VERSION}-cudnn-devel-ubuntu24.04 AS builder
+
+# Install Python and build dependencies
+RUN apt-get update -y && \
+ apt-get install -y python3.10 python3-venv python3-dev git curl && \
+ apt-get clean && rm -rf /var/lib/apt/lists/* && \
+ curl -LsSf https://astral.sh/uv/install.sh | sh && \
+ mv /root/.local/bin/uv /usr/local/bin/ && \
+ mv /root/.local/bin/uvx /usr/local/bin/
+
+WORKDIR /app
+
+# Copy dependency files
+COPY pyproject.toml ./pyproject.toml
+
+# Install dependencies with GPU extras (--no-install-project since api/src doesn't exist yet)
+# UV_PYTHON_INSTALL_DIR keeps the uv-managed interpreter at a stage-shareable path so the
+# runtime stage can find the venv's python target via COPY --from=builder.
+ENV UV_HTTP_TIMEOUT=120 UV_HTTP_RETRIES=3 \
+ UV_PYTHON_INSTALL_DIR=/opt/uv-python
+
+# GPU_EXTRA selects the torch wheel set: "gpu" (cu126, keeps Maxwell/Pascal
+# working) or "gpu-cu128" (cu128, adds Blackwell / RTX 50-series). Overridden
+# per target in docker-bake.hcl.
+ARG GPU_EXTRA=gpu
+
+RUN uv venv --python 3.10 && \
+ uv sync --extra ${GPU_EXTRA} --no-cache --no-install-project
+
+# Stage 2: Runtime - Use smaller runtime image
+FROM --platform=$BUILDPLATFORM nvcr.io/nvidia/cuda:${CUDA_VERSION}-cudnn-runtime-ubuntu24.04
+
+# Install runtime dependencies + uv (needed by entrypoint.sh)
+RUN apt-get update -y && \
+ apt-get install -y python3.10 espeak-ng espeak-ng-data libsndfile1 ffmpeg curl && \
+ curl -LsSf https://astral.sh/uv/install.sh | sh && \
+ mv /root/.local/bin/uv /usr/local/bin/ && \
+ mv /root/.local/bin/uvx /usr/local/bin/ && \
+ apt-get clean && rm -rf /var/lib/apt/lists/* && \
+ mkdir -p /usr/share/espeak-ng-data && \
+ ln -s /usr/lib/*/espeak-ng-data/* /usr/share/espeak-ng-data/ && \
+ useradd -m -u 1001 appuser && \
+ mkdir -p /app/api/src/models/v1_0 && \
+ chown -R appuser:appuser /app
+
+WORKDIR /app
+
+# Copy uv-managed Python interpreter (the venv's bin/python symlinks into here)
+COPY --from=builder /opt/uv-python /opt/uv-python
+
+# Copy virtual environment from builder
+COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv
+
+# Download model in runtime stage so download_model.py is present for runtime re-downloads via entrypoint
+COPY --chown=appuser:appuser docker/scripts/download_model.py ./download_model.py
+ARG DOWNLOAD_MODEL=true
+RUN if [ "$DOWNLOAD_MODEL" = "true" ]; then \
+ /app/.venv/bin/python download_model.py --output api/src/models/v1_0 && \
+ chown -R appuser:appuser /app/api/src/models; \
+ fi
+
+# Japanese support requires the UniDic dictionary (~526MB on disk) for fugashi/MeCab.
+# Enabled by default; set --build-arg INCLUDE_JAPANESE=false to skip and shave the image.
+# Keep true. Setting false silently breaks ja generation.
+ARG INCLUDE_JAPANESE=true
+RUN if [ "$INCLUDE_JAPANESE" = "true" ]; then \
+ /app/.venv/bin/python -m unidic download && \
+ chown -R appuser:appuser /app/.venv/lib/python*/site-packages/unidic; \
+ fi
+
+# Copy project files
+COPY --chown=appuser:appuser pyproject.toml ./pyproject.toml
+COPY --chown=appuser:appuser api ./api
+COPY --chown=appuser:appuser web ./web
+COPY --chown=appuser:appuser VERSION ./VERSION
+COPY --chown=appuser:appuser docker/scripts/entrypoint.sh ./entrypoint.sh
+RUN chmod +x ./entrypoint.sh
+
+USER appuser
+
+# Set environment variables
+ENV PATH="/app/.venv/bin:$PATH" \
+ PYTHONUNBUFFERED=1 \
+ PYTHONPATH=/app:/app/api \
+ UV_LINK_MODE=copy \
+ USE_GPU=true \
+ PHONEMIZER_ESPEAK_PATH=/usr/bin \
+ PHONEMIZER_ESPEAK_DATA=/usr/share/espeak-ng-data \
+ ESPEAK_DATA_PATH=/usr/share/espeak-ng-data \
+ DEVICE="gpu"
+
+# Run FastAPI server
+CMD ["./entrypoint.sh"]
\ No newline at end of file
diff --git a/docker/gpu/docker-compose.yml b/docker/gpu/docker-compose.yml
index 17d6484c..c068e732 100644
--- a/docker/gpu/docker-compose.yml
+++ b/docker/gpu/docker-compose.yml
@@ -1,10 +1,15 @@
-name: kokoro-tts-gpu
+name: kokoro-fastapi-gpu
services:
kokoro-tts:
# image: ghcr.io/remsky/kokoro-fastapi-gpu:v${VERSION}
build:
context: ../..
- dockerfile: docker/gpu/Dockerfile
+ dockerfile: docker/gpu/Dockerfile.optimized
+ # Default builds cu126 wheels, multi-arch (Maxwell through Ada: GTX 9xx, 10xx, 20xx, 30xx, 40xx).
+ # Temp fix for NVIDIA Blackwell / RTX 50-series, uncomment:
+ # args:
+ # GPU_EXTRA: gpu-cu128
+ # CUDA_VERSION: 12.8.1
volumes:
- ../../api:/app/api
user: "1001:1001" # Ensure container runs as UID 1001 (appuser)
@@ -15,6 +20,9 @@ services:
- USE_GPU=true
- PYTHONUNBUFFERED=1
- API_LOG_LEVEL=DEBUG
+ - DOWNLOAD_MODEL=true
+ # - ALLOW_DEV_UNLOAD=true
+ # - ENABLE_DEBUG_ENDPOINTS=true
deploy:
resources:
reservations:
diff --git a/docker/rocm/Dockerfile b/docker/rocm/Dockerfile
index 9b0d19fa..b77dd09c 100644
--- a/docker/rocm/Dockerfile
+++ b/docker/rocm/Dockerfile
@@ -49,6 +49,14 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv venv --python 3.12 && \
uv sync --extra rocm
+# Japanese support requires the UniDic dictionary (~526MB on disk) for fugashi/MeCab.
+# Enabled by default; set --build-arg INCLUDE_JAPANESE=false to skip and shave the image.
+# Keep true. Setting false silently breaks ja generation.
+ARG INCLUDE_JAPANESE=true
+RUN if [ "$INCLUDE_JAPANESE" = "true" ]; then \
+ .venv/bin/python -m unidic download; \
+ fi
+
# Run kdb files (shape files for MIOpen)
ENV ROCM_VERSION=6.4.4
COPY --chown=appuser:appuser docker/rocm/kdb_install.sh /tmp/
@@ -65,18 +73,31 @@ RUN cd /tmp && wget https://archive.archlinux.org/packages/r/rocblas/rocblas-${R
# Copy project files including models
COPY --chown=appuser:appuser api ./api
COPY --chown=appuser:appuser web ./web
+COPY --chown=appuser:appuser VERSION ./VERSION
COPY --chown=appuser:appuser docker/scripts/ ./
+COPY --chown=appuser:appuser docker/rocm/warmup_miopen.py /app/docker/rocm/warmup_miopen.py
RUN chmod +x ./entrypoint.sh
-# Set all environment variables in one go
+# Pre-create MIOpen cache dirs so named-volume mounts inherit appuser
+# ownership (Docker would otherwise create the mount target as root:root
+# and MIOpen as uid 1001 could not write to it on first run).
+RUN mkdir -p /home/appuser/.config/miopen /home/appuser/.cache/miopen
+
+# Set all environment variables in one go.
+# MIOPEN_FIND_MODE=2 (FAST): reuse the on-disk find DB when present, fall back
+# to immediate-mode heuristics otherwise. Cheap default that avoids the 5-60s
+# per-shape kernel search on first hit. See docker/rocm/warmup_miopen.py to
+# pre-populate the cache for best-case latency. Override at runtime to redo
+# the search (e.g. MIOPEN_FIND_MODE=3 + MIOPEN_FIND_ENFORCE=3).
ENV PYTHONUNBUFFERED=1 \
PYTHONPATH=/app:/app/api \
PATH="/app/.venv/bin:$PATH" \
UV_LINK_MODE=copy \
USE_GPU=true \
DOWNLOAD_MODEL=true \
- DEVICE="gpu"
+ DEVICE="gpu" \
+ MIOPEN_FIND_MODE=2
# Run FastAPI server through entrypoint.sh
CMD ["./entrypoint.sh"]
diff --git a/docker/rocm/docker-compose.yml b/docker/rocm/docker-compose.yml
index 8a9fc731..ed8f2f30 100644
--- a/docker/rocm/docker-compose.yml
+++ b/docker/rocm/docker-compose.yml
@@ -1,5 +1,7 @@
+name: kokoro-fastapi-rocm
services:
kokoro-tts:
+ # image: ghcr.io/remsky/kokoro-fastapi-rocm:v${VERSION}
build:
context: ../..
dockerfile: docker/rocm/Dockerfile
@@ -13,25 +15,34 @@ services:
- 993
- 996
restart: 'always'
+ # Named volumes persist the MIOpen find DB and kernel cache at the paths
+ # MIOpen actually writes to ($HOME/.config/miopen and $HOME/.cache/miopen
+ # for appuser). Defined at the bottom of this file. Survives
+ # `docker compose down`; `docker compose down -v` clears them. Inspect
+ # contents with `docker volume inspect kokoro-fastapi-rocm_miopen_cache`.
volumes:
- - ./kokoro-tts/config:/root/.config/miopen
- - ./kokoro-tts/cache:/root/.cache/miopen
+ - miopen_config:/home/appuser/.config/miopen
+ - miopen_cache:/home/appuser/.cache/miopen
ports:
- 8880:8880
environment:
- USE_GPU=true
+ # - ALLOW_DEV_UNLOAD=true
+ # - ENABLE_DEBUG_ENDPOINTS=true
- TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1
- # IMPORTANT: This is only required for RDNA 2 GPUs. You do not need the following steps if you use GPUS that are RDNA 1 (gfx1030) or older.
- # ROCm's MIOpen libray will be slow if it has to figure out the optimal kernel shapes for each model
- # See documentation on performancing tuning: https://github.com/ROCm/MIOpen/blob/develop/docs/conceptual/tuningdb.rst
- # The volumes above cache the MIOpen shape files and user database for subsequent runs
+ # MIOPEN_FIND_MODE=2 is set by default in the Dockerfile. It reuses the
+ # on-disk find DB and skips the expensive per-process kernel search.
+ # The named volumes above persist that cache across runs.
+ # See: https://github.com/ROCm/MIOpen/blob/develop/docs/conceptual/tuningdb.rst
#
- # Steps:
- # 1. Run Kokoro once with the following environment variables set:
+ # To pre-populate the cache for best-case latency (recommended for RDNA 2+):
+ # 1. Override the env to force an exhaustive search:
# - MIOPEN_FIND_MODE=3
# - MIOPEN_FIND_ENFORCE=3
- # 2. Generate various recordings using sample data (e.g. first couple paragraphs of Dracula); this will be slow
- # 3. Comment out/remove the previously set environment variables
- # 4. Add the following environment variables to enable caching of model shapes:
- # - MIOPEN_FIND_MODE=2
- # 5. Restart the container and run Kokoro again, it should be much faster
+ # 2. Run docker/rocm/warmup_miopen.py inside the container, or generate
+ # audio for varied input lengths (e.g. a few paragraphs of Dracula).
+ # 3. Remove the overrides. The default (FIND_MODE=2) will reuse the cache.
+
+volumes:
+ miopen_config:
+ miopen_cache:
diff --git a/docker/rocm/warmup_miopen.py b/docker/rocm/warmup_miopen.py
new file mode 100644
index 00000000..dfc438b5
--- /dev/null
+++ b/docker/rocm/warmup_miopen.py
@@ -0,0 +1,85 @@
+"""MIOpen kernel warmup for Kokoro on ROCm.
+
+Kokoro's tensor shape varies with phoneme count, and MIOpen compiles a
+unique kernel per shape. Without a populated find DB each new length pays
+a 5-60s search. This sweeps lengths 1..340 to warm the cache; ~2 hours on
+Strix Halo, runs once per ROCm/PyTorch upgrade, survives reboots.
+
+Opt-in and unverified outside @realugbun's Strix Halo iGPU (recipe from
+#454); reports from other hardware welcome.
+
+Run inside the container with FIND_MODE overridden so MIOpen performs the
+search (image default is FIND_MODE=2, which reuses the on-disk cache):
+
+ docker exec -it bash -lc '\
+ MIOPEN_FIND_MODE=3 MIOPEN_FIND_ENFORCE=3 \
+ python /app/docker/rocm/warmup_miopen.py'
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+import time
+from pathlib import Path
+
+import torch
+from kokoro import KModel
+
+APP_ROOT = Path(os.environ.get("KOKORO_APP_ROOT", "/app"))
+MODEL_DIR = APP_ROOT / "api/src/models/v1_0"
+VOICE_PATH = APP_ROOT / "api/src/voices/v1_0/af_heart.pt"
+MAX_PHONEMES = int(os.environ.get("WARMUP_MAX_PHONEMES", "340"))
+
+
+def main() -> int:
+ if not torch.cuda.is_available():
+ print("CUDA/HIP device not available; aborting.", file=sys.stderr)
+ return 1
+
+ find_mode = os.environ.get("MIOPEN_FIND_MODE", "")
+ if find_mode == "2":
+ print(
+ "WARNING: MIOPEN_FIND_MODE=2 is set. The warmup needs MODE=3 to "
+ "actually run the kernel search. Re-run with "
+ "MIOPEN_FIND_MODE=3 MIOPEN_FIND_ENFORCE=3.",
+ file=sys.stderr,
+ )
+
+ print(f"[{time.strftime('%H:%M:%S')}] Loading model on GPU...", flush=True)
+ model = KModel(
+ config=str(MODEL_DIR / "config.json"),
+ model=str(MODEL_DIR / "kokoro-v1_0.pth"),
+ ).eval().cuda()
+ voice = torch.load(VOICE_PATH, weights_only=True)
+
+ print(f"[{time.strftime('%H:%M:%S')}] Warming lengths 1..{MAX_PHONEMES}...", flush=True)
+ total = 0.0
+ for n in range(1, MAX_PHONEMES + 1):
+ ps = ("a " * ((n + 1) // 2))[:n]
+ ref_s = voice[min(len(ps), 509)]
+ torch.cuda.synchronize()
+ t0 = time.time()
+ try:
+ model(ps, ref_s, speed=1)
+ except Exception as e:
+ print(f"ERROR at n={n}: {e}", file=sys.stderr)
+ continue
+ torch.cuda.synchronize()
+ elapsed = time.time() - t0
+ total += elapsed
+ if n % 10 == 0:
+ print(
+ f"[{time.strftime('%H:%M:%S')}] {n:3d}/{MAX_PHONEMES} | "
+ f"this={elapsed:5.1f}s | total={total / 60:5.1f}m",
+ flush=True,
+ )
+
+ print(f"\nWarmup complete in {total / 60:.1f} minutes")
+ print("Restart the container (or unset the FIND_MODE override) so the "
+ "default MIOPEN_FIND_MODE=2 picks up the populated cache.")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/docker/scripts/download_model.py b/docker/scripts/download_model.py
index 67a9409c..7a376b2f 100644
--- a/docker/scripts/download_model.py
+++ b/docker/scripts/download_model.py
@@ -3,10 +3,24 @@
import json
import os
+import sys
from pathlib import Path
from urllib.request import urlretrieve
-from loguru import logger
+
+def _log(msg: str) -> None:
+ print(msg, file=sys.stderr, flush=True)
+
+
+class _Logger:
+ def info(self, msg: str) -> None:
+ _log(msg)
+
+ def error(self, msg: str) -> None:
+ _log(f"ERROR: {msg}")
+
+
+logger = _Logger()
def verify_files(model_path: str, config_path: str) -> bool:
@@ -28,7 +42,7 @@ def verify_files(model_path: str, config_path: str) -> bool:
# Verify config file is valid JSON
with open(config_path) as f:
- config = json.load(f)
+ json.load(f)
# Check model file size (should be non-zero)
if os.path.getsize(model_path) == 0:
diff --git a/docker/scripts/download_model.sh b/docker/scripts/download_model.sh
index 22166e37..a9ef7014 100644
--- a/docker/scripts/download_model.sh
+++ b/docker/scripts/download_model.sh
@@ -82,7 +82,7 @@ if verify_files "$MODEL_PATH" "$CONFIG_PATH"; then
fi
# Define URLs
-BASE_URL="https://github.com/remsky/Kokoro-FastAPI/releases/download/v1.4"
+BASE_URL="https://github.com/remsky/Kokoro-FastAPI/releases/download/v0.1.4"
MODEL_URL="$BASE_URL/$MODEL_FILE"
CONFIG_URL="$BASE_URL/$CONFIG_FILE"
diff --git a/docker/test-client/Dockerfile b/docker/test-client/Dockerfile
new file mode 100644
index 00000000..c9000df2
--- /dev/null
+++ b/docker/test-client/Dockerfile
@@ -0,0 +1,50 @@
+# TTS API test client image.
+#
+# A portable validator that drives a running OpenAI-compatible TTS server
+# over its HTTP API and asserts the responses meet a documented contract:
+# format-correct audio bytes, sensible streaming, valid phonemes, coherent
+# word timestamps, and (for transcription tests) intelligible speech.
+#
+# Test code is NOT baked in. Mount the suite at /tests/integration and the
+# entrypoint discovers it. This lets the image stay stable while suites
+# iterate. Currently driven from api/tests/integration in this repo.
+#
+# Whisper weights ARE baked in (~470MB) so CI never depends on Hugging Face
+# at test time.
+
+FROM python:3.11-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ UV_LINK_MODE=copy \
+ UV_PROJECT_ENVIRONMENT=/opt/test-client/.venv \
+ WHISPER_MODEL=/opt/whisper/small
+
+# libsndfile1 is required by `soundfile` for decoding flac/ogg/wav formats.
+# faster-whisper's own pyav dependency handles mp3/opus/aac. curl fetches
+# the prebaked Whisper weights and the uv installer.
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends libsndfile1 curl ca-certificates \
+ && rm -rf /var/lib/apt/lists/* \
+ && curl -LsSf https://astral.sh/uv/install.sh | sh \
+ && mv /root/.local/bin/uv /usr/local/bin/uv \
+ && mv /root/.local/bin/uvx /usr/local/bin/uvx
+
+WORKDIR /opt/test-client
+COPY pyproject.toml uv.lock ./
+RUN uv sync --frozen --no-install-project
+
+# Pre-fetch the Whisper small CT2 weights into a known path. Loading via
+# directory path skips Hugging Face entirely at test time.
+RUN mkdir -p /opt/whisper/small \
+ && curl -fSL https://github.com/remsky/Kokoro-FastAPI/releases/download/v0.1.4/faster-whisper-small-ct2.tar.gz \
+ | tar -xz -C /opt/whisper/small \
+ && echo 'Whisper small weights extracted to /opt/whisper/small'
+
+# Put the venv bin first so `pytest` resolves to the locked one.
+ENV PATH="/opt/test-client/.venv/bin:$PATH"
+
+WORKDIR /tests
+COPY pytest.ini /tests/pytest.ini
+ENTRYPOINT ["pytest", "-m", "integration", "-v", "--tb=short", "-s"]
+CMD ["integration/"]
diff --git a/docker/test-client/pyproject.toml b/docker/test-client/pyproject.toml
new file mode 100644
index 00000000..6803d044
--- /dev/null
+++ b/docker/test-client/pyproject.toml
@@ -0,0 +1,17 @@
+[project]
+name = "tts-api-test-client"
+version = "0"
+description = "Test client for OpenAI-compatible TTS HTTP APIs. Drives a running server, decodes the audio, transcribes with faster-whisper, asserts contract + intelligibility."
+requires-python = ">=3.11,<3.12"
+dependencies = [
+ "faster-whisper==1.2.1",
+ "jiwer==4.0.0",
+ "soundfile==0.13.1",
+ "pytest==8.3.5",
+ "pytest-asyncio==0.25.3",
+ "httpx==0.26.0",
+ "openai==2.37.0",
+]
+
+[tool.uv]
+package = false
diff --git a/docker/test-client/pytest.ini b/docker/test-client/pytest.ini
new file mode 100644
index 00000000..62dd1a8d
--- /dev/null
+++ b/docker/test-client/pytest.ini
@@ -0,0 +1,4 @@
+[pytest]
+python_files = test_*.py
+markers =
+ integration: end-to-end test against a running Kokoro server (this image's reason to exist).
diff --git a/docker/test-client/uv.lock b/docker/test-client/uv.lock
new file mode 100644
index 00000000..aca5774c
--- /dev/null
+++ b/docker/test-client/uv.lock
@@ -0,0 +1,718 @@
+version = 1
+revision = 3
+requires-python = "==3.11.*"
+
+[[package]]
+name = "annotated-doc"
+version = "0.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.13.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
+]
+
+[[package]]
+name = "av"
+version = "17.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4e/f0/8c8dca97ae0cf00e8e2a53bb5cb9aca5fd484f585ef3e9b412200aff3ebd/av-17.0.1.tar.gz", hash = "sha256:fbcbd4aa43bca6a8691816283112d1659a27f407bbeb66d1397023691339f5d4", size = 4411938, upload-time = "2026-04-18T17:12:34.29Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4c/82/e7007dcef7bd2d2c377e2e85977701384f42d19fc808c2ccb3a99eaf58f2/av-17.0.1-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:987f4f46ceae4da6c614dcbd2b8149be9dbf680c3bb7a6841c58af9cff4d9230", size = 23238802, upload-time = "2026-04-18T17:11:51.166Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/aa/858b09a08ea6f83f91be44b5a5adad13ae8d9ac8b80fda27e73c24bfb160/av-17.0.1-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:d97f54e55b18a74912f479c1978aadd1341d38d892dee95bb5c2f2dccfa72f32", size = 18709338, upload-time = "2026-04-18T17:11:53.286Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/8b/8de3fd21c4b0b74d44337421abeab0e71462337fb6a28fff888e0c356cbd/av-17.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e6eee84afa48d0e9321047cd3e4facd44b401493f6bdc753e2e1d1e7c9e6d13e", size = 34007351, upload-time = "2026-04-18T17:11:56.116Z" },
+ { url = "https://files.pythonhosted.org/packages/02/28/167b291356c2cc315a2d62a95b0ceace72b5b0bf547de30b89313110f032/av-17.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c58c71bffd9383908c85695ac61d3184c668accb04a5bd1b262e0fb8d09f60a5", size = 36345295, upload-time = "2026-04-18T17:11:59.125Z" },
+ { url = "https://files.pythonhosted.org/packages/04/fa/aae56f2ff2c204c408641e1120f5ca5ce9c3390cf5362245c6f1158704b5/av-17.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:42d6745d30a410ec9b22aef79a52a7ab5a001eb8f5adfd952946606a30983318", size = 35183754, upload-time = "2026-04-18T17:12:01.697Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/bd/776046f27093aef80155a204ca7d82a887ae4ee72ba4ef8411b46ea7898c/av-17.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3ed6bcd7021fe55832f95b8ef78dd01a4cb21faf3cd71f1e1bf4f20bf100b278", size = 37430809, upload-time = "2026-04-18T17:12:04.231Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/d5/3261bd2c6b7f6c0aa8379fc970d1ecf496330990b992ad28607785074268/av-17.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:9af524e8632a54032e361d6b88895bd3e7c6212ca560de60f5ccc525323c764c", size = 28889649, upload-time = "2026-04-18T17:12:07.04Z" },
+ { url = "https://files.pythonhosted.org/packages/98/39/381104e427a0c7231d2ec0d25d538d58fc20fc0458846b95860d3ef8073b/av-17.0.1-cp311-abi3-win_arm64.whl", hash = "sha256:50e58a473d65ea29b645e45c9fd8518a6783737135683ecc40571a91592bdfe4", size = 21918412, upload-time = "2026-04-18T17:12:09.312Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.5.20"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
+ { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
+ { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
+ { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "ctranslate2"
+version = "4.7.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "pyyaml" },
+ { name = "setuptools" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d9/a3/8279b0d040f5db0569bafe0b89c984d54457cf0da0f65479714c523219a2/ctranslate2-4.7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fae4c008552832520876f708b05f6023b32f0d549606d39e22e925c1b97e17cc", size = 1259553, upload-time = "2026-05-19T06:41:16.051Z" },
+ { url = "https://files.pythonhosted.org/packages/08/3d/eb60218895baa71cb50640dceb29aae162549f325b9714b06c3dc0c26d17/ctranslate2-4.7.2-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:d942a2a069d653d14555a4c19033a15ab58d2302c7cb8c47e8795be75fb577e0", size = 11919986, upload-time = "2026-05-19T06:41:18.414Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/c2/2bd99cd2f12e45c821ed9f4d7461b587bffbab791856ec83fcbccf722faf/ctranslate2-4.7.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48198d9e4dc8d7d86086e929b24c7595480e880ed242b07981ebbc4a7e6cd9a5", size = 16700271, upload-time = "2026-05-19T06:41:22.314Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/65/891ff8a6115f92a68edd280de0aa52f31308bfea08c356d4ba53a5b8d1ad/ctranslate2-4.7.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb2ba50abdf695c7b1991556af905a654234f5e05e79280518d0a2cfc8b22397", size = 38841486, upload-time = "2026-05-19T06:41:27.979Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/38/a57a845d141cc6cfae1beb65c67115fb88b1ed9cc850c377a1a76e94b407/ctranslate2-4.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:e77879fcaf90ce96f23a8981824bd1a08a3067589f80630442695faa6363d1d3", size = 18844431, upload-time = "2026-05-19T06:41:31.992Z" },
+]
+
+[[package]]
+name = "distro"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
+]
+
+[[package]]
+name = "faster-whisper"
+version = "1.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "av" },
+ { name = "ctranslate2" },
+ { name = "huggingface-hub" },
+ { name = "onnxruntime" },
+ { name = "tokenizers" },
+ { name = "tqdm" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/05/99/49ee85903dee060d9f08297b4a342e5e0bcfca2f027a07b4ee0a38ab13f9/faster_whisper-1.2.1-py3-none-any.whl", hash = "sha256:79a66ad50688c0b794dd501dc340a736992a6342f7f95e5811be60b5224a26a7", size = 1118909, upload-time = "2025-10-31T11:35:47.794Z" },
+]
+
+[[package]]
+name = "filelock"
+version = "3.29.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
+]
+
+[[package]]
+name = "flatbuffers"
+version = "25.12.19"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" },
+]
+
+[[package]]
+name = "fsspec"
+version = "2026.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d5/8d/1c51c094345df128ca4a990d633fe1a0ff28726c9e6b3c41ba65087bba1d/fsspec-2026.4.0.tar.gz", hash = "sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4", size = 312760, upload-time = "2026-04-29T20:42:38.635Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2", size = 203402, upload-time = "2026-04-29T20:42:36.842Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "hf-xet"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/74/d8/5c06fc76461418326a7decf8367480c35be11a41fd938633929c60a9ec6b/hf_xet-1.5.0.tar.gz", hash = "sha256:e0fb0a34d9f406eed88233e829a67ec016bec5af19e480eac65a233ea289a948", size = 837196, upload-time = "2026-05-06T06:18:15.583Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3d/fb/69ff198a82cae7eb1a69fb84d93b3a3e4816564d76817fe541ddc96874eb/hf_xet-1.5.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dad0dc84e941b8ba3c860659fe1fdc35c049d47cce293f003287757e971a8f56", size = 4030814, upload-time = "2026-05-06T06:17:57.933Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a", size = 3798444, upload-time = "2026-05-06T06:17:55.79Z" },
+ { url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949", size = 4465986, upload-time = "2026-05-06T06:17:44.886Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/a2/546f47f464737b3edbab6f8ddb57f2599b93d2cbb66f06abb475ccb48651/hf_xet-1.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9a0ee58cd18d5ea799f7ed11290bbccbe56bdd8b1d97ca74b9cc49a3945d7a3b", size = 4259865, upload-time = "2026-05-06T06:17:42.639Z" },
+ { url = "https://files.pythonhosted.org/packages/95/7f/1be593c1f28613be2e196473481cd81bfc5910795e30a34e8f744f6cac4f/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e60df5a42e9bed8628b6416af2cba4cba57ae9f02de226a06b020d98e1aab18", size = 4459835, upload-time = "2026-05-06T06:18:08.026Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/b2/703569fc881f3284487e68cda7b42179978480da3c438042a6bbbb4a671c/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4b35549ce62601b84da4ff9b24d970032ace3d4430f52d91bcbb26c901d6c690", size = 4672414, upload-time = "2026-05-06T06:18:09.864Z" },
+ { url = "https://files.pythonhosted.org/packages/af/37/1b6def445c567286b50aa3b33828158e135b1be44938dde59f11382a500c/hf_xet-1.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:2806c7c17b4d23f8d88f7c4814f838c3b6150773fe339c20af23e1cfaf2797e4", size = 3977238, upload-time = "2026-05-06T06:18:23.621Z" },
+ { url = "https://files.pythonhosted.org/packages/62/94/3b66b148778ee100dcfd69c2ca22b57b41b44d3063ceec934f209e9184ce/hf_xet-1.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:b6c9df403040248c76d808d3e047d64db2d923bae593eb244c41e425cf6cd7be", size = 3806916, upload-time = "2026-05-06T06:18:21.7Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.26.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+ { name = "sniffio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bd/26/2dc654950920f499bd062a211071925533f821ccdca04fa0c2fd914d5d06/httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf", size = 125671, upload-time = "2023-12-20T11:02:58.032Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/9b/4937d841aee9c2c8102d9a4eeb800c7dad25386caabb4a1bf5010df81a57/httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd", size = 75862, upload-time = "2023-12-20T11:02:55.395Z" },
+]
+
+[[package]]
+name = "huggingface-hub"
+version = "1.16.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "filelock" },
+ { name = "fsspec" },
+ { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
+ { name = "httpx" },
+ { name = "packaging" },
+ { name = "pyyaml" },
+ { name = "tqdm" },
+ { name = "typer" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/48/0f/ed994dbade67a54407c28cab96ef845e0e6d25500be56aca6394f8bfc9dd/huggingface_hub-1.16.1.tar.gz", hash = "sha256:7f1dc4c5ec21aed69be630ad0c3378616be16f3de1a47b141c0e812965d9c832", size = 792534, upload-time = "2026-05-21T18:40:00.908Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/79/621a7dbb80c70974f73a597275351ebe03ce5bc65cb5f8f4acb5859252bc/huggingface_hub-1.16.1-py3-none-any.whl", hash = "sha256:64340de934b9ce37857ef85a82de72f5629e8a270f9119eabb12bf495eb53c22", size = 668176, upload-time = "2026-05-21T18:39:58.596Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.15"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "jiter"
+version = "0.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e4/13/daa722f5765c393576f466378f9dfd29d77c9bed939e0688f96afa3601ea/jiter-0.15.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0f862193b8696249d22ec433e85fd2ab0ad9596bc3e45e6c0bc55e8aeba97be2", size = 310899, upload-time = "2026-05-19T10:07:12.89Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/82/2d2551829b082f4b6d82b9f939b031fb808a10aab1ec0664f82e150bb9a2/jiter-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1303d4d68a9b051ea90502402063ecf3807da00ad2affa19ca1ae3b90b3c5f67", size = 314963, upload-time = "2026-05-19T10:07:14.539Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/0a/8b1a51466f7fe9f31dbe4bc7e0ca848674f9825e0f737b929b97e8c60aa7/jiter-0.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:392b8ab019e5502d08aff85c6272209c24bc2cbe706ea82a56368f524236614a", size = 341730, upload-time = "2026-05-19T10:07:15.869Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/2a/e71dea19822e2e404e83992a08c1d6b9b617bb944f28c9c2fbd85d02c91e/jiter-0.15.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:773b6eb282ce11ee19f05f6b2d4404fa308e5bbd353b0b80a0262caad6db2cd7", size = 366214, upload-time = "2026-05-19T10:07:17.259Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/59/97e1fa539d124a509a00ab7f669289d1c1d236ecabf12948a18f16c91082/jiter-0.15.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2c0c44d569ce0f2850f5c926f8caeb5f245fbc84475aeb36efccc2103e6dbd", size = 459527, upload-time = "2026-05-19T10:07:18.741Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/7a/4a68d331aef8cf2e2393c14a3aacb635c62aa86071b0229899fb5baaa907/jiter-0.15.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:032396229564bca02440396bd327710719f724f5e7b7e9f7a8eb3faa4a2c2281", size = 375451, upload-time = "2026-05-19T10:07:20.208Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/7e/1c445c2b6f0e30a274dc8082e0c3c7825411cce80d726bccd697c98cc8d3/jiter-0.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d37768fce7f88dd2a8c6091f2325dea27d30d30d5c6e7a1c0f0af77723b708", size = 349428, upload-time = "2026-05-19T10:07:22.372Z" },
+ { url = "https://files.pythonhosted.org/packages/00/94/e20d38984fc17a636371bffd2ae0f698124fdc8e75ef969cd2da6ba7cea7/jiter-0.15.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2c9cb907439d20bd0c7d7565ca01ee52234203208433749bae5b516907526928", size = 355405, upload-time = "2026-05-19T10:07:23.916Z" },
+ { url = "https://files.pythonhosted.org/packages/94/fa/4d09f814779d0ea80a28ed8e4c6662ec9a4a8ecef0ac52190ebac6262d14/jiter-0.15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9100ddbec09741cc66feb0fc6773f8bdbd0e3c345689368f260082ff85dcc0cd", size = 393688, upload-time = "2026-05-19T10:07:25.854Z" },
+ { url = "https://files.pythonhosted.org/packages/54/9d/8eb5d4fb8bf7e93a75964a5da71a75c67c864baf7fa3f98598187b3c7e57/jiter-0.15.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ae1b0d82ac2d987f9ea512b1c9adfcc71a28de3dea3a6039b54d76cffda9901e", size = 520853, upload-time = "2026-05-19T10:07:27.303Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/2c/5e07874e59e623a943a0acf1552a80d05b70f31b402287a8fc6d7ec634c7/jiter-0.15.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8020c99ec13a7db2b6f96cbe82ef4721c88b426a4892f27478044af0284615ef", size = 551016, upload-time = "2026-05-19T10:07:28.846Z" },
+ { url = "https://files.pythonhosted.org/packages/22/ed/d2d34422143474cadc15b60d482b1c35683dbc5c63c24346ddd0df09bcaf/jiter-0.15.0-cp311-cp311-win32.whl", hash = "sha256:42bfb257930800cf43e7c62c832402c704ab60797c992faf88d20e903eac8f32", size = 209518, upload-time = "2026-05-19T10:07:30.431Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/7d/52778b930e5cc3e52a37d950b1c10494244308b4329b25a0ff0d88303a81/jiter-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:860a74063284a2ae9bfedd694f299cc2c68e2696c5f3d440cc9d18bb81b9dd04", size = 200565, upload-time = "2026-05-19T10:07:32.125Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/4f/d9b4067feb69b3fa6eb0488e1b59e2ad5b463fe39f59e527eab2aca00bb0/jiter-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:37a10c377ce3a4a85f4a67f28b7afe093154cde77eaf248a72e856aa08b4d865", size = 195488, upload-time = "2026-05-19T10:07:33.846Z" },
+ { url = "https://files.pythonhosted.org/packages/65/43/1fc62172aa98b50a7de9a25554060db510f85c89cfbed0dfe13e1907a139/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:411fa4dfa5a7ae3d11491027ffb9beadec3996010a986862db70d91abba1c750", size = 305585, upload-time = "2026-05-19T10:09:35.995Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/c4/dd58fcd9e2df83666e5c1c1347bef58ce919cd8efc3ffa38aeea62ce493b/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:2b0074e2f56eb2dacca1689760fd2852a068f85a0547a157b82cb4cafeb6768b", size = 306936, upload-time = "2026-05-19T10:09:37.435Z" },
+ { url = "https://files.pythonhosted.org/packages/39/86/b695e16f1180c07f43ea98e73ecd21cf63fa2e1b0c1103739013784d11ae/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:913d02d29c9606643418d9ccfc3b72492ab25a6bf7889934e09a3490f8d3438b", size = 342453, upload-time = "2026-05-19T10:09:39.294Z" },
+ { url = "https://files.pythonhosted.org/packages/34/56/55d76614af37fe3f22a3347d1e410d2a15da581997cb2da499a625000bb5/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b15d3ec9b0449c40e85319bdb4caa8b77ab526e74f5532ed94bec15e2f66822c", size = 345606, upload-time = "2026-05-19T10:09:40.727Z" },
+]
+
+[[package]]
+name = "jiwer"
+version = "4.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "rapidfuzz" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/12/1e/963dfc249d5bbe88079b57a22556e981ddd9208e4b6116e48bdbfc01f26b/jiwer-4.0.0.tar.gz", hash = "sha256:ae9c051469102a61ef0927100baeeb4546f78d180c9b0948281d08eaf44c191e", size = 28074, upload-time = "2025-06-19T16:05:23.004Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/c9/172c525330c739a068c01050759a6f855ce16212db10a0359e690a03ac48/jiwer-4.0.0-py3-none-any.whl", hash = "sha256:7efaf0bd336b095d99ddef9dd67e1ee829d75d58aa2a81d9639870b01d6d95ea", size = 23034, upload-time = "2025-06-19T16:05:21.821Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" },
+ { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" },
+ { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" },
+ { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" },
+ { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" },
+ { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" },
+ { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" },
+ { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" },
+ { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" },
+]
+
+[[package]]
+name = "onnxruntime"
+version = "1.26.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "flatbuffers" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "protobuf" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/81/29a9eb470994a75eb7b3ccf32be314d7c66675a00ac7b50294816cc2db27/onnxruntime-1.26.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ee1109ef4ef27cad90e823399e61e03b3c6c7bfe0fb820b4baf3678c15be8b3c", size = 18005108, upload-time = "2026-05-08T19:08:11.728Z" },
+ { url = "https://files.pythonhosted.org/packages/66/c7/73efa6c8a4000c38fcc14947d84f234a17e5d66f203b37b7f1ad4a7b46eb/onnxruntime-1.26.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35c7c7b0ac2e02001d28fab6c9fc24e9abc5e6faa35e6e19c63cecf1406ba89f", size = 16043752, upload-time = "2026-05-08T19:07:10.707Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/3f/8de630f595daf6ce884d4dd95afd2a60e70ec6572e52bfee3aa2229befab/onnxruntime-1.26.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11a8df4dcfe9ad5ff0bd71a7571dbed019fabc7594676c89fe8b86ea029c246f", size = 18176043, upload-time = "2026-05-08T19:07:33.735Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/21/9f041de20787cd85498bd48e0ec4d098bf2a6c486e25b24b8dae1bf492b2/onnxruntime-1.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:e6456718125fd777c673f3b78d4a9ab58d6adea641e9afae85ee6444f0e0e9a9", size = 13023165, upload-time = "2026-05-08T19:08:00.633Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/82/3b9fe0ead2557cc3adf74c74c141bd1c7c4c6a9548c610af37df199f4512/onnxruntime-1.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:cd920e45b730e4a87833e2910d8ca375aaca9da6ccc09e24bce463b3356d637f", size = 12789514, upload-time = "2026-05-08T19:07:49.433Z" },
+]
+
+[[package]]
+name = "openai"
+version = "2.37.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "distro" },
+ { name = "httpx" },
+ { name = "jiter" },
+ { name = "pydantic" },
+ { name = "sniffio" },
+ { name = "tqdm" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/32/50/5901f01ef14e6c27788beb91e54fef5d6204fb5fb9e97402fc8a14de2e32/openai-2.37.0.tar.gz", hash = "sha256:f4bc562cc5f3a43d40d678105572d9d44765f6e0f50c125f63055419b72f4bd9", size = 754706, upload-time = "2026-05-15T22:30:35.428Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ed/4c/bce61680d0699a78a405fd9a67989b175ba020590428831aab2ab1d2be7c/openai-2.37.0-py3-none-any.whl", hash = "sha256:814633888b8f3b1ffd6615697c6e4ef93632d08b7c2e28c8c5ef3556e5a10107", size = 1303238, upload-time = "2026-05-15T22:30:32.767Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "protobuf"
+version = "7.35.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/60/fd/5b1491d9e4b586d621c54f4c36b888714164b6875f8d6afa3f9072906a51/protobuf-7.35.0.tar.gz", hash = "sha256:a2efd84605f41e559f1881b0912b44099d0a2ac9bf46b3474823f10fb393b0e6", size = 458677, upload-time = "2026-05-19T23:02:29.197Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/ee/93d06e358a4aa32280b00e722d3ea0a1f25fc3cc5778d80581c9cca2c10e/protobuf-7.35.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:66be6c513931c794fa92c080ffee41671390da3d79da219cf9c0c0907f035dda", size = 433225, upload-time = "2026-05-19T23:02:19.884Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/39/1c76c2da93f3c507e958e0aecee2391cc44d4625de6c728bbc555195b5a8/protobuf-7.35.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:fcbe42a4ac09d3ec9c987ddfcd956afd0b15f1ff613bd8371bde9405ffd5c8e5", size = 328847, upload-time = "2026-05-19T23:02:22.3Z" },
+ { url = "https://files.pythonhosted.org/packages/91/1a/39f7ce90a238c1a987a4d81ec26379e02ca0aff367de68e4a1fa474215b9/protobuf-7.35.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:4cbf5cc286130e06a6c9bbefac442431173906dfcc979712183d4adcc01b37ee", size = 344030, upload-time = "2026-05-19T23:02:23.591Z" },
+ { url = "https://files.pythonhosted.org/packages/70/5b/6baf9008817964454055ff3fe65f1de0b5f1e26c80c82f7fb108b7cd4ea3/protobuf-7.35.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:6c0f98f10c8a05ea30f8993dfef2de093d27b490fdae78bb60c8343795d55011", size = 327130, upload-time = "2026-05-19T23:02:24.637Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/e5/e46adb0badc388bfb84877a5f9f026aff63f60e611016cf64dbe77e05446/protobuf-7.35.0-cp310-abi3-win32.whl", hash = "sha256:4c4617b83ade0e279d1d2bfe04025a1adb87f9ed657de038620dc0ff959357f6", size = 428946, upload-time = "2026-05-19T23:02:25.741Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/ab/547fbd9e16d879dd13c167478f8ae0a83a428008ca07a5e06acdc23ad473/protobuf-7.35.0-cp310-abi3-win_amd64.whl", hash = "sha256:f05bcadf9a2a6b8dda047007075135fb7d08c73d9177aabc067e1be46881a201", size = 439996, upload-time = "2026-05-19T23:02:26.808Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/ef/50433d346c56657a70d27f156c7b349ac59a068b01de4eb796e747eecc43/protobuf-7.35.0-py3-none-any.whl", hash = "sha256:c13f325cf242bad135c350629eeb5d54b24228eb472fb3e2e9ebbd4c5dc20ca0", size = 171659, upload-time = "2026-05-19T23:02:27.842Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.13.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.46.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
+ { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
+ { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
+ { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
+ { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
+ { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
+ { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
+ { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
+ { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
+ { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
+ { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
+ { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "8.3.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "0.25.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f2/a8/ecbc8ede70921dd2f544ab1cadd3ff3bf842af27f87bbdea774c7baa1d38/pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a", size = 54239, upload-time = "2025-01-28T18:37:58.729Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467, upload-time = "2025-01-28T18:37:56.798Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+ { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+ { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+ { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+ { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+]
+
+[[package]]
+name = "rapidfuzz"
+version = "3.14.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/f9/3c41a7be8855803f4f6c713b472226a98d31d41869d98f64f4ca790510d6/rapidfuzz-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e251126d48615e1f02b4a178f2cd0cd4f0332b8a019c01a2e10480f7552554b4", size = 1952372, upload-time = "2026-04-07T11:13:58.32Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/89/c2557e37531d03465193bff0ab9de70b468420a807d71a26a65100635459/rapidfuzz-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ab449c9abd0d4e1f8145dce0798a4c822a1a1933d613c764a641bea88b8bdab", size = 1159782, upload-time = "2026-04-07T11:14:00.127Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/b2/ffeeb7eca1a897d51b998f4c0ef0281696c3b06abcca4f88f9def708ffe1/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb2829fedd672dd7107267189dabe2bbe07972801d636014417c6861eb89e358", size = 1383677, upload-time = "2026-04-07T11:14:01.696Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/d0/4539e42a2d596e068f7738f279638a4a74edd1fbb6f8594e2458058979c6/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d50e5861872935fece391351cbb5ba21d1bced277cf5e1143d207a0a35f1925", size = 3168906, upload-time = "2026-04-07T11:14:03.29Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/1c/3ec897eb9d8b05308aa8ef6ae4ed64b088ad521a3f9d8ff469e7e97bc2b0/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:7092a216728f80c960bd6b3807275d1ee318b168986bd5dc523349581d4890b8", size = 1478176, upload-time = "2026-04-07T11:14:04.94Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/ba/970c03a12ce20a5399e22afe9f8932fd4cd1265b8a8461d0e63b00eb4eae/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9669753caef7fdc6529f6adcc5883ed98d65976445d9322e7dbdb6b697feee13", size = 2402441, upload-time = "2026-04-07T11:14:07.228Z" },
+ { url = "https://files.pythonhosted.org/packages/81/93/61d351cae60c1d0e21ba5ff1a1015ad045539ed215da9d6e302204ed887a/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:823b1b9d9230809d8edcc18872770764bfe8ef4357995e16744047c8ccf0e489", size = 2511628, upload-time = "2026-04-07T11:14:09.234Z" },
+ { url = "https://files.pythonhosted.org/packages/87/52/374d2d4f60fd98155142a869323aa221e30868cfa1f15171a0f64070c247/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f0b2af76b7e7060c09e1a0dfa9410eb19369cbe6164509bff2ef94094b54d2b6", size = 4275480, upload-time = "2026-04-07T11:14:11.332Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/04/82e7989bc9ec20a15b720a335c5cb6b0724bf6582013898f90a3280cfccd/rapidfuzz-3.14.5-cp311-cp311-win32.whl", hash = "sha256:c5801a89604c65ab4cc9e91b23bc4076d0ca80efd8c976fb63843d7879a85d7f", size = 1725627, upload-time = "2026-04-07T11:14:13.217Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/b5/eca8ac5609bc9bcb02bb6ff87fa5983cc92b8772d66a431556ab8a8c178f/rapidfuzz-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:d7ca16637c0ede8243f84074044bd0b2335a0341421f8227c85756de2d18c819", size = 1545977, upload-time = "2026-04-07T11:14:14.766Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/e1/dbf318de28f65fa2cdd0a9dfbdee380f8199eb83b19259bc4f8592551b4e/rapidfuzz-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:8c90cdf8516d9057e502aa6003cea71cf5ec27cc44699ca52412b502a04761bb", size = 816827, upload-time = "2026-04-07T11:14:16.788Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/ee/e71853bf82846c5c2174b924b71d8e8099fb05ff87c958a720380b434ba3/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:578e6051f6d5e6200c259b47a103cf06bb875ab5814d17333fc0b5c290b22f4c", size = 1888603, upload-time = "2026-04-07T11:16:18.223Z" },
+ { url = "https://files.pythonhosted.org/packages/36/82/40f67b730f32be2ebad9f62add1571c754f52249254b2e88af094b907eee/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbf1b8bb2695415b347f3727da1addca2acb82c9b97ac86bebf8b1bead1eb12d", size = 1120599, upload-time = "2026-04-07T11:16:20.682Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/9f/a3635cc4ec8fc6e14b46e7db1f7f8763d8c4bef33dcc124eea2e6cb2c8f3/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4a8f5cc84c7ad6bffa0e9947b33eb343ad66e6b53e94fe54378a5508c5ed53", size = 1348524, upload-time = "2026-04-07T11:16:23.451Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/1b/2b229520f0b48464cfcd7aa758f74551d12c9bc4ab544022a60210aab064/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c6d85283629646fa87acc22c66b30ea9d4de7f6fdf887daa2e30fa041829b5", size = 3099302, upload-time = "2026-04-07T11:16:25.858Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889, upload-time = "2026-04-07T11:16:28.487Z" },
+]
+
+[[package]]
+name = "rich"
+version = "15.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
+]
+
+[[package]]
+name = "setuptools"
+version = "82.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
+]
+
+[[package]]
+name = "shellingham"
+version = "1.5.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
+]
+
+[[package]]
+name = "soundfile"
+version = "0.13.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi" },
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e1/41/9b873a8c055582859b239be17902a85339bec6a30ad162f98c9b0288a2cc/soundfile-0.13.1.tar.gz", hash = "sha256:b2c68dab1e30297317080a5b43df57e302584c49e2942defdde0acccc53f0e5b", size = 46156, upload-time = "2025-01-25T09:17:04.831Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/28/e2a36573ccbcf3d57c00626a21fe51989380636e821b341d36ccca0c1c3a/soundfile-0.13.1-py2.py3-none-any.whl", hash = "sha256:a23c717560da2cf4c7b5ae1142514e0fd82d6bbd9dfc93a50423447142f2c445", size = 25751, upload-time = "2025-01-25T09:16:44.235Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/ab/73e97a5b3cc46bba7ff8650a1504348fa1863a6f9d57d7001c6b67c5f20e/soundfile-0.13.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:82dc664d19831933fe59adad199bf3945ad06d84bc111a5b4c0d3089a5b9ec33", size = 1142250, upload-time = "2025-01-25T09:16:47.583Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/e5/58fd1a8d7b26fc113af244f966ee3aecf03cb9293cb935daaddc1e455e18/soundfile-0.13.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:743f12c12c4054921e15736c6be09ac26b3b3d603aef6fd69f9dde68748f2593", size = 1101406, upload-time = "2025-01-25T09:16:49.662Z" },
+ { url = "https://files.pythonhosted.org/packages/58/ae/c0e4a53d77cf6e9a04179535766b3321b0b9ced5f70522e4caf9329f0046/soundfile-0.13.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9c9e855f5a4d06ce4213f31918653ab7de0c5a8d8107cd2427e44b42df547deb", size = 1235729, upload-time = "2025-01-25T09:16:53.018Z" },
+ { url = "https://files.pythonhosted.org/packages/57/5e/70bdd9579b35003a489fc850b5047beeda26328053ebadc1fb60f320f7db/soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:03267c4e493315294834a0870f31dbb3b28a95561b80b134f0bd3cf2d5f0e618", size = 1313646, upload-time = "2025-01-25T09:16:54.872Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/df/8c11dc4dfceda14e3003bb81a0d0edcaaf0796dd7b4f826ea3e532146bba/soundfile-0.13.1-py2.py3-none-win32.whl", hash = "sha256:c734564fab7c5ddf8e9be5bf70bab68042cd17e9c214c06e365e20d64f9a69d5", size = 899881, upload-time = "2025-01-25T09:16:56.663Z" },
+ { url = "https://files.pythonhosted.org/packages/14/e9/6b761de83277f2f02ded7e7ea6f07828ec78e4b229b80e4ca55dd205b9dc/soundfile-0.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:1e70a05a0626524a69e9f0f4dd2ec174b4e9567f4d8b6c11d38b5c289be36ee9", size = 1019162, upload-time = "2025-01-25T09:16:59.573Z" },
+]
+
+[[package]]
+name = "tokenizers"
+version = "0.23.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huggingface-hub" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748, upload-time = "2026-04-27T14:43:25.606Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/39/b87a87d5bb9470610b80a2d31df42fcffeaf35118b8b97952b2aff598cc7/tokenizers-0.23.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224", size = 3146732, upload-time = "2026-04-27T14:43:15.427Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/6a/068ed9f6e444c9d7e9d55ce134181325700f3d7f30410721bdc8f848d727/tokenizers-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e", size = 3054954, upload-time = "2026-04-27T14:43:13.745Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/36/e006edf031154cba92b8416057d92c3abe3635e4c4b0aa0b5b9bb39dde70/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3", size = 3374081, upload-time = "2026-04-27T14:43:01.241Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/ef/7735d226f9c7f874a6bee5e3f27fb25ecabdf207d37b8cf45286d0795893/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6", size = 3247641, upload-time = "2026-04-27T14:43:03.856Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/d9/24827036f6e21297bfffda0768e58eb6096a4f411e932964a01707857931/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959", size = 3585624, upload-time = "2026-04-27T14:43:10.664Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/9a/22f3582b3a4f49358293a5206e25317621ee4526bfe9cdaa0f07a12e770e/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51", size = 3844062, upload-time = "2026-04-27T14:43:05.643Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/65/b8f8814eef95800f20721384136d9a1d22241d50b2874357cb70542c392f/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a", size = 3460098, upload-time = "2026-04-27T14:43:08.854Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/d5/1353e5f677ec27c2494fb6a6725e82d56c985f53e90ec511369e7e4f02c6/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4", size = 3346235, upload-time = "2026-04-27T14:43:12.377Z" },
+ { url = "https://files.pythonhosted.org/packages/71/89/39b6b8fc073fb6d413d0147aa333dc7eff7be65639ac9d19930a0b21bf33/tokenizers-0.23.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a", size = 3426398, upload-time = "2026-04-27T14:43:07.264Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/80/127c854da64827e5b79264ce524993a90dddcb320e5cd42412c5c02f9e8a/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e", size = 9823279, upload-time = "2026-04-27T14:43:17.222Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/ba/44c2502feb1a058f096ddfb4e0996ef3225a01a388e1a9b094e91689fe93/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288", size = 9644986, upload-time = "2026-04-27T14:43:19.45Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/c1/464019a9fb059870bfe4eebb4ba12208f3042035e258bf5e782906bd3847/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4", size = 9976181, upload-time = "2026-04-27T14:43:21.463Z" },
+ { url = "https://files.pythonhosted.org/packages/79/94/3ac1432bda31626071e9b6a12709b97ae05131c804b94c8f3ac622c5da32/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96", size = 10113853, upload-time = "2026-04-27T14:43:23.617Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/dd/631b21433c771b1382535326f0eca80b9c9cee2e64961dd993bc9ac4669e/tokenizers-0.23.1-cp310-abi3-win32.whl", hash = "sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948", size = 2536263, upload-time = "2026-04-27T14:43:29.888Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/2553f72aaf65a2797d4229e37fa7fbe38ffbf3e32912d31bdd78b3323e59/tokenizers-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7", size = 2798223, upload-time = "2026-04-27T14:43:28.51Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/2b/2be299bab55fc595e3d38567edb1a87f86e594842968fa9515a07bdcf422/tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9", size = 2664127, upload-time = "2026-04-27T14:43:26.949Z" },
+]
+
+[[package]]
+name = "tqdm"
+version = "4.67.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
+]
+
+[[package]]
+name = "tts-api-test-client"
+version = "0"
+source = { virtual = "." }
+dependencies = [
+ { name = "faster-whisper" },
+ { name = "httpx" },
+ { name = "jiwer" },
+ { name = "openai" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "soundfile" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "faster-whisper", specifier = "==1.2.1" },
+ { name = "httpx", specifier = "==0.26.0" },
+ { name = "jiwer", specifier = "==4.0.0" },
+ { name = "openai", specifier = "==2.37.0" },
+ { name = "pytest", specifier = "==8.3.5" },
+ { name = "pytest-asyncio", specifier = "==0.25.3" },
+ { name = "soundfile", specifier = "==0.13.1" },
+]
+
+[[package]]
+name = "typer"
+version = "0.25.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-doc" },
+ { name = "click" },
+ { name = "rich" },
+ { name = "shellingham" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
diff --git a/examples/assorted_checks/benchmarks/benchmark_first_token_stream_unified.py b/examples/assorted_checks/benchmarks/benchmark_first_token_stream_unified.py
index c3b6d922..07c4cac1 100644
--- a/examples/assorted_checks/benchmarks/benchmark_first_token_stream_unified.py
+++ b/examples/assorted_checks/benchmarks/benchmark_first_token_stream_unified.py
@@ -10,6 +10,9 @@
base_url="http://localhost:8880/v1", api_key="not-needed-for-local"
)
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+EXAMPLES_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, os.pardir, os.pardir))
+
def measure_first_token_requests(
text: str, output_dir: str, tokens: int, run_number: int
@@ -46,7 +49,7 @@ def measure_first_token_requests(
# Save complete audio
audio_filename = f"benchmark_tokens{tokens}_run{run_number}_stream.wav"
audio_path = os.path.join(output_dir, audio_filename)
- results["audio_path"] = audio_path
+ results["audio_path"] = os.path.relpath(audio_path, EXAMPLES_DIR)
first_chunk_time = None
chunks = []
@@ -114,7 +117,7 @@ def measure_first_token_openai(
# Save complete audio
audio_filename = f"benchmark_tokens{tokens}_run{run_number}_stream_openai.wav"
audio_path = os.path.join(output_dir, audio_filename)
- results["audio_path"] = audio_path
+ results["audio_path"] = os.path.relpath(audio_path, EXAMPLES_DIR)
first_chunk_time = None
all_audio_data = bytearray()
diff --git a/examples/assorted_checks/benchmarks/benchmark_model_unload.py b/examples/assorted_checks/benchmarks/benchmark_model_unload.py
new file mode 100644
index 00000000..e7209f46
--- /dev/null
+++ b/examples/assorted_checks/benchmarks/benchmark_model_unload.py
@@ -0,0 +1,311 @@
+#!/usr/bin/env python3
+"""Measure VRAM reclaimed by POST /dev/unload, and the lazy reload after it.
+
+Runs two scenarios (short + longform), each: baseline -> request -> unload ->
+request (triggers lazy reload). A background monitor samples whole-GPU VRAM and
+system RAM the whole time, so the plots show the drop on unload and the climb
+back on reload, exactly as the host sees it (nvidia-smi view).
+
+Usage (from examples/, model server already up on :8880):
+ uv run --extra benchmarks assorted_checks/benchmarks/benchmark_model_unload.py
+ uv run --extra benchmarks assorted_checks/benchmarks/benchmark_model_unload.py --long-lines 60
+
+Note: reclaim scales with how hard the model has been worked (the activation
+pool grows under load and is fully returned on unload), so a bigger --long-lines
+yields a bigger longform reclaim.
+"""
+import os
+import sys
+import time
+import queue
+import argparse
+import threading
+from datetime import datetime
+
+import requests
+import pandas as pd
+import matplotlib.pyplot as plt
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from lib.shared_utils import ( # noqa: E402
+ get_system_metrics,
+ save_json_results,
+ save_audio_file,
+ get_audio_length,
+ write_benchmark_stats,
+)
+from lib.shared_plotting import STYLE_CONFIG, setup_plot # noqa: E402
+
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+TEXT_FILE = os.path.join(SCRIPT_DIR, "the_time_machine_hg_wells.txt")
+SAMPLE_INTERVAL = 0.2 # seconds between metric samples
+
+SHORT_TEXT = (
+ "The model has been released from GPU memory, "
+ "and will reload automatically on the next request."
+)
+
+
+class SystemMonitor:
+ """Background sampler of system + GPU metrics (whole-GPU via nvidia-smi)."""
+
+ def __init__(self, interval=SAMPLE_INTERVAL):
+ self.interval = interval
+ self.metrics_queue = queue.Queue()
+ self.stop_event = threading.Event()
+ self.metrics_timeline = []
+ self.start_time = None
+
+ def _monitor_loop(self):
+ while not self.stop_event.is_set():
+ metrics = get_system_metrics()
+ metrics["relative_time"] = time.time() - self.start_time
+ self.metrics_queue.put(metrics)
+ time.sleep(self.interval)
+
+ def start(self):
+ self.start_time = time.time()
+ self.thread = threading.Thread(target=self._monitor_loop, daemon=True)
+ self.thread.start()
+
+ def stop(self):
+ self.stop_event.set()
+ if hasattr(self, "thread"):
+ self.thread.join(timeout=2)
+ while True:
+ try:
+ self.metrics_timeline.append(self.metrics_queue.get_nowait())
+ except queue.Empty:
+ break
+ return self.metrics_timeline
+
+
+def load_longform(n_lines: int) -> str:
+ lines = [ln.strip() for ln in open(TEXT_FILE, encoding="utf-8") if ln.strip()]
+ return " ".join(lines[:n_lines])
+
+
+def speech(base_url: str, text: str, voice: str, fmt: str) -> tuple[bytes, float]:
+ """Fire one /v1/audio/speech request, return (audio_bytes, gen_seconds)."""
+ t0 = time.time()
+ r = requests.post(
+ f"{base_url}/v1/audio/speech",
+ json={"model": "kokoro", "input": text, "voice": voice, "response_format": fmt},
+ timeout=600,
+ )
+ r.raise_for_status()
+ return r.content, time.time() - t0
+
+
+def unload(base_url: str) -> None:
+ r = requests.post(f"{base_url}/dev/unload", timeout=60)
+ r.raise_for_status()
+
+
+def run_scenario(label, text, args) -> dict:
+ """Normalize -> load -> warm -> unload -> reload, monitored throughout.
+
+ A leading unload clears any reserved pool inherited from a prior scenario,
+ so each run is reproducible from the same floor regardless of history.
+ """
+ print(f"\n=== scenario: {label} ({len(text)} chars / {len(text.split())} words) ===")
+ monitor = SystemMonitor()
+ events = []
+
+ def mark(name):
+ events.append({"time": time.time() - monitor.start_time, "label": name})
+ print(f" t={events[-1]['time']:6.2f}s {name}")
+
+ monitor.start()
+
+ mark("unload (reset)") # normalize: drop any inherited pool
+ unload(args.url)
+ time.sleep(args.settle)
+
+ mark("load (cold)") # first request reloads the model lazily
+ audio1, gen_cold1 = speech(args.url, text, args.voice, args.format)
+ mark("warm request") # second request runs warm
+ _, gen_warm = speech(args.url, text, args.voice, args.format)
+ mark("loaded")
+ time.sleep(args.settle) # steady loaded window
+
+ mark("unload") # the drop we care about
+ unload(args.url)
+ time.sleep(args.settle) # floor window
+
+ mark("reload (cold)") # request after unload -> lazy reload
+ audio2, gen_cold2 = speech(args.url, text, args.voice, args.format)
+ mark("reloaded")
+ time.sleep(args.settle)
+
+ timeline = monitor.stop()
+
+ # save audio so runs are inspectable, like the other benchmarks
+ audio_dir = os.path.join(SCRIPT_DIR, "output_audio")
+ p1 = save_audio_file(audio1, f"model_unload_{label}_loaded", audio_dir)
+ save_audio_file(audio2, f"model_unload_{label}_reloaded", audio_dir)
+ alen = get_audio_length(audio1, audio_dir) if args.format == "wav" else None
+
+ df = pd.DataFrame(timeline)
+ gpu_gb = df["gpu_memory_used"] / 1024
+ t = lambda name: next(e["time"] for e in events if e["label"] == name)
+
+ def window(lo, hi):
+ return gpu_gb[(df["relative_time"] >= lo) & (df["relative_time"] < hi)]
+
+ # steady loaded level (after warm gen settled) vs floor after the unload
+ loaded = window(t("loaded"), t("unload")).median()
+ floor = window(t("unload"), t("reload (cold)")).median()
+ reclaim = loaded - floor
+ cold_gen = (gen_cold1 + gen_cold2) / 2
+
+ stats = {
+ "loaded_gpu_gb": round(loaded, 3),
+ "floor_after_unload_gb": round(floor, 3),
+ "reclaimed_gb": round(reclaim, 3),
+ "reclaimed_mib": round(reclaim * 1024, 1),
+ "warm_gen_seconds": round(gen_warm, 2),
+ "cold_reload_gen_seconds": round(cold_gen, 2),
+ "reload_penalty_seconds": round(cold_gen - gen_warm, 2),
+ }
+ if alen:
+ stats["audio_seconds"] = round(alen, 1)
+ stats["warm_rtf"] = round(gen_warm / alen, 3)
+ print(f" -> reclaimed {stats['reclaimed_mib']} MiB on unload; "
+ f"reload cost +{stats['reload_penalty_seconds']}s")
+
+ plot_path = os.path.join(SCRIPT_DIR, "output_plots", f"model_unload_{label}.png")
+ plot_timeline(df, events, stats, loaded, floor, plot_path, label)
+
+ return {"label": label, "stats": stats, "events": events, "timeline": timeline,
+ "audio_sample": p1}
+
+
+def plot_timeline(df, events, stats, loaded, floor, output_path, label):
+ """Single-panel dark GPU timeline: loaded plateau, unload drop, reload climb.
+
+ The story is VRAM, so the plot is GPU-only. Phases are shown as shaded
+ windows with large horizontal labels instead of cramped rotated text.
+ """
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
+ plt.style.use("dark_background")
+ t = df["relative_time"]
+ gpu_gb = df["gpu_memory_used"] / 1024
+ ev = {e["label"]: e["time"] for e in events}
+ fs = STYLE_CONFIG["font_sizes"]
+
+ fig, ax = plt.subplots(figsize=(13, 6))
+ fig.patch.set_facecolor(STYLE_CONFIG["background_color"])
+
+ # ── phase shading: loaded window (cyan tint) vs unloaded floor (grey tint)
+ if "load (cold)" in ev and "unload" in ev:
+ ax.axvspan(ev["load (cold)"], ev["unload"],
+ color=STYLE_CONFIG["secondary_color"], alpha=0.07)
+ if "unload" in ev and "reload (cold)" in ev:
+ ax.axvspan(ev["unload"], ev["reload (cold)"],
+ color=STYLE_CONFIG["text_color"], alpha=0.06)
+
+ # ── gpu trace
+ ax.plot(t, gpu_gb, color=STYLE_CONFIG["primary_color"], linewidth=2.5)
+
+ # ── loaded / floor reference lines + reclaim band
+ ax.axhline(loaded, color=STYLE_CONFIG["secondary_color"], linestyle="--",
+ alpha=0.7, linewidth=1.5, label=f"loaded {loaded:.2f} GB")
+ ax.axhline(floor, color=STYLE_CONFIG["text_color"], linestyle="--",
+ alpha=0.55, linewidth=1.5, label=f"unloaded floor {floor:.2f} GB")
+ ax.fill_between(t, floor, loaded, color=STYLE_CONFIG["secondary_color"], alpha=0.08)
+ ax.annotate(
+ f"reclaimed {stats['reclaimed_mib']:.0f} MiB",
+ xy=(0.5, (loaded + floor) / 2), xycoords=("axes fraction", "data"),
+ ha="center", va="center", color=STYLE_CONFIG["text_color"],
+ fontsize=fs["title"], fontweight="bold",
+ bbox=dict(facecolor=STYLE_CONFIG["background_color"],
+ edgecolor=STYLE_CONFIG["secondary_color"], alpha=0.85, pad=6),
+ )
+
+ # ── big phase labels centered over each window (no rotation)
+ ytop = gpu_gb.max()
+
+ def phase_label(x0, x1, txt):
+ ax.text((x0 + x1) / 2, ytop + 0.04, txt, ha="center", va="bottom",
+ fontsize=fs["label"], fontweight="bold",
+ color=STYLE_CONFIG["text_color"], alpha=0.9)
+
+ if "load (cold)" in ev and "unload" in ev:
+ phase_label(ev["load (cold)"], ev["unload"], "model loaded")
+ if "unload" in ev and "reload (cold)" in ev:
+ phase_label(ev["unload"], ev["reload (cold)"], "unloaded (idle)")
+
+ # ── boundary markers at the two moments that matter (split sides so the
+ # labels don't collide when the idle window is short)
+ for name, disp, ha in [
+ ("unload", "unload ", "right"),
+ ("reload (cold)", " request → reload", "left"),
+ ]:
+ if name in ev:
+ ax.axvline(ev[name], color=STYLE_CONFIG["text_color"], alpha=0.45,
+ linestyle=":", linewidth=1.3)
+ ax.text(ev[name], floor - 0.06, disp, ha=ha, va="top",
+ fontsize=fs["text"] + 1, color=STYLE_CONFIG["text_color"],
+ alpha=0.85)
+
+ ax.set_ylim(floor - 0.22, ytop + 0.22)
+ setup_plot(fig, ax, f"Model Unload / Reload VRAM ({label})",
+ xlabel="Time (seconds)", ylabel="GPU Memory (GB)")
+ ax.legend(loc="center right", fontsize=fs["text"] + 1)
+
+ plt.tight_layout()
+ plt.savefig(output_path, dpi=300, bbox_inches="tight")
+ plt.close()
+ print(f" plot -> {output_path}")
+
+
+def replot_from_saved():
+ """Re-render plots from output_data/model_unload_results.json, no server."""
+ import json
+ path = os.path.join(SCRIPT_DIR, "output_data", "model_unload_results.json")
+ with open(path, encoding="utf-8") as f:
+ data = json.load(f)
+ for r in data["scenarios"]:
+ df = pd.DataFrame(r["timeline"])
+ stats = r["stats"]
+ plot_path = os.path.join(SCRIPT_DIR, "output_plots", f"model_unload_{r['label']}.png")
+ plot_timeline(df, r["events"], stats, stats["loaded_gpu_gb"],
+ stats["floor_after_unload_gb"], plot_path, r["label"])
+ print("\nreplotted.")
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--url", default="http://localhost:8880")
+ ap.add_argument("--voice", default="af_heart")
+ ap.add_argument("--format", default="wav", help="wav enables audio-length/RTF")
+ ap.add_argument("--long-lines", type=int, default=30,
+ help="paragraphs of the source text for the longform run")
+ ap.add_argument("--settle", type=float, default=2.0,
+ help="seconds to hold between phases (plot readability)")
+ ap.add_argument("--replot", action="store_true",
+ help="re-render plots from saved results json (no server needed)")
+ args = ap.parse_args()
+
+ if args.replot:
+ return replot_from_saved()
+
+ results = [
+ run_scenario("short", SHORT_TEXT, args),
+ run_scenario("longform", load_longform(args.long_lines), args),
+ ]
+
+ save_json_results(
+ {"timestamp": datetime.now().isoformat(), "scenarios": results},
+ os.path.join(SCRIPT_DIR, "output_data", "model_unload_results.json"),
+ )
+ write_benchmark_stats(
+ [{"title": f"Model Unload - {r['label']}", "stats": r["stats"]} for r in results],
+ os.path.join(SCRIPT_DIR, "output_data", "model_unload_stats.txt"),
+ )
+ print("\ndone.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/assorted_checks/benchmarks/output_data/first_token_benchmark_stream.json b/examples/assorted_checks/benchmarks/output_data/first_token_benchmark_stream.json
index c78b5ab3..600e32de 100644
--- a/examples/assorted_checks/benchmarks/output_data/first_token_benchmark_stream.json
+++ b/examples/assorted_checks/benchmarks/output_data/first_token_benchmark_stream.json
@@ -6,7 +6,7 @@
"total_time": 1.818483829498291,
"time_to_first_chunk": 1.8067498207092285,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run1_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run1_stream.wav",
"audio_length": 3.45,
"target_tokens": 10,
"actual_tokens": 10,
@@ -18,7 +18,7 @@
"total_time": 1.6271553039550781,
"time_to_first_chunk": 1.610968828201294,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run2_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run2_stream.wav",
"audio_length": 3.45,
"target_tokens": 10,
"actual_tokens": 10,
@@ -30,7 +30,7 @@
"total_time": 1.5759549140930176,
"time_to_first_chunk": 1.561316967010498,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run3_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run3_stream.wav",
"audio_length": 3.45,
"target_tokens": 10,
"actual_tokens": 10,
@@ -42,7 +42,7 @@
"total_time": 1.615680456161499,
"time_to_first_chunk": 1.6035709381103516,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run4_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run4_stream.wav",
"audio_length": 3.45,
"target_tokens": 10,
"actual_tokens": 10,
@@ -54,7 +54,7 @@
"total_time": 1.6515357494354248,
"time_to_first_chunk": 1.6268820762634277,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run5_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run5_stream.wav",
"audio_length": 3.45,
"target_tokens": 10,
"actual_tokens": 10,
@@ -66,7 +66,7 @@
"total_time": 7.368175268173218,
"time_to_first_chunk": 3.4540352821350098,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run1_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run1_stream.wav",
"audio_length": 15.825,
"target_tokens": 50,
"actual_tokens": 50,
@@ -78,7 +78,7 @@
"total_time": 6.931752443313599,
"time_to_first_chunk": 3.1553661823272705,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run2_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run2_stream.wav",
"audio_length": 15.825,
"target_tokens": 50,
"actual_tokens": 50,
@@ -90,7 +90,7 @@
"total_time": 6.867500066757202,
"time_to_first_chunk": 3.127124309539795,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run3_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run3_stream.wav",
"audio_length": 15.825,
"target_tokens": 50,
"actual_tokens": 50,
@@ -102,7 +102,7 @@
"total_time": 6.933881521224976,
"time_to_first_chunk": 3.1872360706329346,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run4_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run4_stream.wav",
"audio_length": 15.825,
"target_tokens": 50,
"actual_tokens": 50,
@@ -114,7 +114,7 @@
"total_time": 7.605916738510132,
"time_to_first_chunk": 3.6397976875305176,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run5_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run5_stream.wav",
"audio_length": 15.825,
"target_tokens": 50,
"actual_tokens": 50,
@@ -126,7 +126,7 @@
"total_time": 14.777218580245972,
"time_to_first_chunk": 3.625889778137207,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run1_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run1_stream.wav",
"audio_length": 30.35,
"target_tokens": 100,
"actual_tokens": 100,
@@ -138,7 +138,7 @@
"total_time": 13.911701202392578,
"time_to_first_chunk": 3.298157215118408,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run2_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run2_stream.wav",
"audio_length": 30.35,
"target_tokens": 100,
"actual_tokens": 100,
@@ -150,7 +150,7 @@
"total_time": 14.451806783676147,
"time_to_first_chunk": 3.8353848457336426,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run3_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run3_stream.wav",
"audio_length": 30.35,
"target_tokens": 100,
"actual_tokens": 100,
@@ -162,7 +162,7 @@
"total_time": 13.941124200820923,
"time_to_first_chunk": 3.3754897117614746,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run4_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run4_stream.wav",
"audio_length": 30.35,
"target_tokens": 100,
"actual_tokens": 100,
@@ -174,7 +174,7 @@
"total_time": 15.717307329177856,
"time_to_first_chunk": 3.6421003341674805,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run5_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run5_stream.wav",
"audio_length": 30.35,
"target_tokens": 100,
"actual_tokens": 100,
@@ -186,7 +186,7 @@
"total_time": 41.16162133216858,
"time_to_first_chunk": 3.7044918537139893,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run1_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run1_stream.wav",
"audio_length": 78.175,
"target_tokens": 250,
"actual_tokens": 250,
@@ -198,7 +198,7 @@
"total_time": 35.43009877204895,
"time_to_first_chunk": 3.1040024757385254,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run2_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run2_stream.wav",
"audio_length": 78.175,
"target_tokens": 250,
"actual_tokens": 250,
@@ -210,7 +210,7 @@
"total_time": 35.285505294799805,
"time_to_first_chunk": 3.657808780670166,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run3_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run3_stream.wav",
"audio_length": 78.175,
"target_tokens": 250,
"actual_tokens": 250,
@@ -222,7 +222,7 @@
"total_time": 34.47842836380005,
"time_to_first_chunk": 3.2033851146698,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run4_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run4_stream.wav",
"audio_length": 78.175,
"target_tokens": 250,
"actual_tokens": 250,
@@ -234,7 +234,7 @@
"total_time": 36.50936222076416,
"time_to_first_chunk": 3.1159815788269043,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run5_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run5_stream.wav",
"audio_length": 78.175,
"target_tokens": 250,
"actual_tokens": 250,
@@ -246,7 +246,7 @@
"total_time": 86.84899735450745,
"time_to_first_chunk": 5.405678987503052,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run1_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run1_stream.wav",
"audio_length": 155.125,
"target_tokens": 500,
"actual_tokens": 500,
@@ -258,7 +258,7 @@
"total_time": 74.72578477859497,
"time_to_first_chunk": 3.966891050338745,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run2_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run2_stream.wav",
"audio_length": 155.125,
"target_tokens": 500,
"actual_tokens": 500,
@@ -270,7 +270,7 @@
"total_time": 68.1974081993103,
"time_to_first_chunk": 3.27712082862854,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run3_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run3_stream.wav",
"audio_length": 155.125,
"target_tokens": 500,
"actual_tokens": 500,
@@ -282,7 +282,7 @@
"total_time": 72.68819260597229,
"time_to_first_chunk": 3.153608560562134,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run4_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run4_stream.wav",
"audio_length": 155.125,
"target_tokens": 500,
"actual_tokens": 500,
@@ -294,7 +294,7 @@
"total_time": 67.94887590408325,
"time_to_first_chunk": 3.954728841781616,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run5_stream.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run5_stream.wav",
"audio_length": 155.125,
"target_tokens": 500,
"actual_tokens": 500,
diff --git a/examples/assorted_checks/benchmarks/output_data/first_token_benchmark_stream_openai.json b/examples/assorted_checks/benchmarks/output_data/first_token_benchmark_stream_openai.json
index 968fffbb..a13f4715 100644
--- a/examples/assorted_checks/benchmarks/output_data/first_token_benchmark_stream_openai.json
+++ b/examples/assorted_checks/benchmarks/output_data/first_token_benchmark_stream_openai.json
@@ -6,7 +6,7 @@
"total_time": 1.638200044631958,
"time_to_first_chunk": 1.6232295036315918,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run1_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run1_stream_openai.wav",
"audio_length": 3.45,
"target_tokens": 10,
"actual_tokens": 10,
@@ -18,7 +18,7 @@
"total_time": 1.4960439205169678,
"time_to_first_chunk": 1.4854960441589355,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run2_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run2_stream_openai.wav",
"audio_length": 3.45,
"target_tokens": 10,
"actual_tokens": 10,
@@ -30,7 +30,7 @@
"total_time": 1.5055279731750488,
"time_to_first_chunk": 1.4948456287384033,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run3_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run3_stream_openai.wav",
"audio_length": 3.45,
"target_tokens": 10,
"actual_tokens": 10,
@@ -42,7 +42,7 @@
"total_time": 1.496837854385376,
"time_to_first_chunk": 1.4835176467895508,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run4_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run4_stream_openai.wav",
"audio_length": 3.45,
"target_tokens": 10,
"actual_tokens": 10,
@@ -54,7 +54,7 @@
"total_time": 1.7330272197723389,
"time_to_first_chunk": 1.7219843864440918,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run5_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run5_stream_openai.wav",
"audio_length": 3.45,
"target_tokens": 10,
"actual_tokens": 10,
@@ -66,7 +66,7 @@
"total_time": 6.865253925323486,
"time_to_first_chunk": 3.1809072494506836,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run1_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run1_stream_openai.wav",
"audio_length": 15.825,
"target_tokens": 50,
"actual_tokens": 50,
@@ -78,7 +78,7 @@
"total_time": 7.975425720214844,
"time_to_first_chunk": 3.2910428047180176,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run2_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run2_stream_openai.wav",
"audio_length": 15.825,
"target_tokens": 50,
"actual_tokens": 50,
@@ -90,7 +90,7 @@
"total_time": 6.793715715408325,
"time_to_first_chunk": 3.210068464279175,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run3_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run3_stream_openai.wav",
"audio_length": 15.825,
"target_tokens": 50,
"actual_tokens": 50,
@@ -102,7 +102,7 @@
"total_time": 6.639606237411499,
"time_to_first_chunk": 3.0641400814056396,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run4_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run4_stream_openai.wav",
"audio_length": 15.825,
"target_tokens": 50,
"actual_tokens": 50,
@@ -114,7 +114,7 @@
"total_time": 8.100529193878174,
"time_to_first_chunk": 3.3910109996795654,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run5_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run5_stream_openai.wav",
"audio_length": 15.825,
"target_tokens": 50,
"actual_tokens": 50,
@@ -126,7 +126,7 @@
"total_time": 15.246968984603882,
"time_to_first_chunk": 3.1980819702148438,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run1_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run1_stream_openai.wav",
"audio_length": 30.35,
"target_tokens": 100,
"actual_tokens": 100,
@@ -138,7 +138,7 @@
"total_time": 15.934760332107544,
"time_to_first_chunk": 4.23082709312439,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run2_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run2_stream_openai.wav",
"audio_length": 30.35,
"target_tokens": 100,
"actual_tokens": 100,
@@ -150,7 +150,7 @@
"total_time": 13.799078226089478,
"time_to_first_chunk": 3.42996883392334,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run3_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run3_stream_openai.wav",
"audio_length": 30.35,
"target_tokens": 100,
"actual_tokens": 100,
@@ -162,7 +162,7 @@
"total_time": 13.400063037872314,
"time_to_first_chunk": 3.2097883224487305,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run4_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run4_stream_openai.wav",
"audio_length": 30.35,
"target_tokens": 100,
"actual_tokens": 100,
@@ -174,7 +174,7 @@
"total_time": 14.833694219589233,
"time_to_first_chunk": 3.1589744091033936,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run5_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run5_stream_openai.wav",
"audio_length": 30.35,
"target_tokens": 100,
"actual_tokens": 100,
@@ -186,7 +186,7 @@
"total_time": 35.49378156661987,
"time_to_first_chunk": 3.852027177810669,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run1_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run1_stream_openai.wav",
"audio_length": 78.175,
"target_tokens": 250,
"actual_tokens": 250,
@@ -198,7 +198,7 @@
"total_time": 33.59433174133301,
"time_to_first_chunk": 3.2059006690979004,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run2_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run2_stream_openai.wav",
"audio_length": 78.175,
"target_tokens": 250,
"actual_tokens": 250,
@@ -210,7 +210,7 @@
"total_time": 34.23120045661926,
"time_to_first_chunk": 3.1464977264404297,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run3_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run3_stream_openai.wav",
"audio_length": 78.175,
"target_tokens": 250,
"actual_tokens": 250,
@@ -222,7 +222,7 @@
"total_time": 36.18487215042114,
"time_to_first_chunk": 3.188844919204712,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run4_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run4_stream_openai.wav",
"audio_length": 78.175,
"target_tokens": 250,
"actual_tokens": 250,
@@ -234,7 +234,7 @@
"total_time": 38.142744302749634,
"time_to_first_chunk": 3.6997063159942627,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run5_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run5_stream_openai.wav",
"audio_length": 78.175,
"target_tokens": 250,
"actual_tokens": 250,
@@ -246,7 +246,7 @@
"total_time": 71.48920440673828,
"time_to_first_chunk": 3.148237943649292,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run1_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run1_stream_openai.wav",
"audio_length": 155.125,
"target_tokens": 500,
"actual_tokens": 500,
@@ -258,7 +258,7 @@
"total_time": 73.53017520904541,
"time_to_first_chunk": 3.464594841003418,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run2_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run2_stream_openai.wav",
"audio_length": 155.125,
"target_tokens": 500,
"actual_tokens": 500,
@@ -270,7 +270,7 @@
"total_time": 75.52278685569763,
"time_to_first_chunk": 3.5506417751312256,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run3_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run3_stream_openai.wav",
"audio_length": 155.125,
"target_tokens": 500,
"actual_tokens": 500,
@@ -282,7 +282,7 @@
"total_time": 69.45922994613647,
"time_to_first_chunk": 3.495962619781494,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run4_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run4_stream_openai.wav",
"audio_length": 155.125,
"target_tokens": 500,
"actual_tokens": 500,
@@ -294,7 +294,7 @@
"total_time": 66.66928672790527,
"time_to_first_chunk": 3.301323175430298,
"error": null,
- "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run5_stream_openai.wav",
+ "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run5_stream_openai.wav",
"audio_length": 155.125,
"target_tokens": 500,
"actual_tokens": 500,
diff --git a/examples/assorted_checks/test_transcription/BASELINE.md b/examples/assorted_checks/test_transcription/BASELINE.md
new file mode 100644
index 00000000..69d0d32a
--- /dev/null
+++ b/examples/assorted_checks/test_transcription/BASELINE.md
@@ -0,0 +1,37 @@
+# Long-Form Baseline
+
+Reference numbers from `test_long_form.py` so future runs have something to
+compare against. All numbers are with `WHISPER_DEVICE=cuda` (the .bat default).
+
+- Voice: `af_heart`
+- Server: local, GPU
+- Whisper: `base.en`, float16, VAD on, CUDA
+- Source: `input/journey_all.txt.gz` (*A Journey to the Centre of the Earth*, Project Gutenberg)
+- Short: `--chars 65000` (~end of chapter 7). Full: entire book.
+
+| Metric | Short (warm) | Full |
+| --- | --- | --- |
+| Input words / chars | 11,468 / 64,996 | 88,884 / 502,766 |
+| Audio length | 66m06s | 507m52s |
+| Synth time / speedup | 1m49s, 36.4x rt | 11m06s, 45.7x rt |
+| Transcribe time / speedup | 1m03s, 62.4x rt | 7m48s, 65.1x rt |
+| Output size | 181.6 MB | 1394.9 MB |
+| Transcript words | 11,518 | 89,173 |
+| **WER (normalized)** | **0.0466** | **0.0334** |
+
+Captured on cu126 GPU build, warm container.
+
+## Cold vs warm
+
+The short test is sensitive to first-run cuDNN autotune: a cold-container short
+synth on the same setup landed at 17x rt (vs 36x warm). The full run is long
+enough to amortize the autotune cost, so it lands at the same number cold or
+warm. When comparing the short numbers, make sure the container has done at
+least one prior synth — otherwise you'll be measuring autotune, not throughput.
+
+## Regression bands
+
+- WER < 0.07
+- Transcript word count within +/-1% of cleaned input
+- Synth >= 25x realtime, warm (depends on GPU; cold first-run on short can drop to ~15-20x)
+- Transcribe >= 40x realtime on CUDA (CPU `base.en` int8 lands ~13-17x)
diff --git a/examples/assorted_checks/test_transcription/README.md b/examples/assorted_checks/test_transcription/README.md
new file mode 100644
index 00000000..24350d5c
--- /dev/null
+++ b/examples/assorted_checks/test_transcription/README.md
@@ -0,0 +1,47 @@
+# Transcription Roundtrip Check
+
+Synthesizes phrases with a running Kokoro server, transcribes the audio with
+[`faster-whisper`](https://github.com/SYSTRAN/faster-whisper), and reports WER
+against the expected text.
+
+- `test_transcription.py`: short English clips per voice, fast sanity check
+ (uses `base.en`, WER).
+- `test_transcription_multilingual.py`: one short clip per non-English voice
+ with native reference text. Defaults to multilingual `small` (~470MB), uses
+ CER for ja/zh and WER otherwise. Output goes to `output_multilingual/`.
+- `test_long_form.py`: multi-hour roundtrip on a full book. See `BASELINE.md`.
+ Windows wrapper: `run_long_form.bat [short|full|synth|transcribe]`
+ (default `short`).
+
+## Run
+
+From the repo root:
+
+```bash
+uv sync --project examples --extra transcription --extra transcription-gpu
+uv run --project examples python examples/assorted_checks/test_transcription/test_transcription.py
+```
+
+Omit `--extra transcription-gpu` to skip the ~1.2 GB cuDNN/cuBLAS download and
+run Whisper on CPU. First run also pulls the `base.en` model (~150 MB) into
+the HF cache.
+
+## Config (env vars)
+
+| Var | Default | Notes |
+| --- | --- | --- |
+| `KOKORO_BASE_URL` | `http://localhost:8880/v1` | Running Kokoro server |
+| `WHISPER_MODEL` | `base.en` | Try `tiny.en` for speed, `small.en` for accuracy |
+| `WHISPER_DEVICE` | `cpu` (script) / `cuda` (bat) | Whisper device |
+| `WHISPER_COMPUTE` | `int8` on cpu, `float16` on cuda | CTranslate2 compute type |
+| `WER_THRESHOLD` | `0.2` | Per-clip pass cutoff (short test only) |
+
+`test_long_form.py` also reads `LONGFORM_VOICE`, `LONGFORM_INPUT`, and
+`LONGFORM_CHARS`; see its module docstring.
+
+## Output
+
+- Short test → `output/` (WAVs + `report.json`).
+- Long-form → `output_long_form/` (WAV, transcript, `long_form_report.json`,
+ and a `*.synth_meta.json` sidecar so `transcribe`-only runs inherit the
+ exact char cap from the prior synth).
diff --git a/examples/assorted_checks/test_transcription/__init__.py b/examples/assorted_checks/test_transcription/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/examples/assorted_checks/test_transcription/input/journey_all.txt.gz b/examples/assorted_checks/test_transcription/input/journey_all.txt.gz
new file mode 100644
index 00000000..b54e7c53
Binary files /dev/null and b/examples/assorted_checks/test_transcription/input/journey_all.txt.gz differ
diff --git a/examples/assorted_checks/test_transcription/output/report.json b/examples/assorted_checks/test_transcription/output/report.json
new file mode 100644
index 00000000..a585ec0f
--- /dev/null
+++ b/examples/assorted_checks/test_transcription/output/report.json
@@ -0,0 +1,42 @@
+[
+ {
+ "voice": "af_heart",
+ "expected": "The quick brown fox jumps over the lazy dog.",
+ "transcript": "The quick brown fox jumps over the lazy dog.",
+ "wer": 0.0,
+ "passed": true,
+ "audio_path": "output/af_heart.wav",
+ "synth_seconds": 1.838453500000469,
+ "transcribe_seconds": 0.8427800000026764
+ },
+ {
+ "voice": "af_bella",
+ "expected": "She sells seashells by the seashore.",
+ "transcript": "She sells seashells by the seashore.",
+ "wer": 0.0,
+ "passed": true,
+ "audio_path": "output/af_bella.wav",
+ "synth_seconds": 1.1176961999990453,
+ "transcribe_seconds": 0.8100260000028356
+ },
+ {
+ "voice": "am_adam",
+ "expected": "Pack my box with five dozen liquor jugs.",
+ "transcript": "Pack my box with 5 dozen liquor jugs.",
+ "wer": 0.0,
+ "passed": true,
+ "audio_path": "output/am_adam.wav",
+ "synth_seconds": 1.2620734999982233,
+ "transcribe_seconds": 0.8112173000008625
+ },
+ {
+ "voice": "af_nicole",
+ "expected": "The five boxing wizards jump quickly across the field.",
+ "transcript": "the five boxing wizards. Jump quickly across the field.",
+ "wer": 0.0,
+ "passed": true,
+ "audio_path": "output/af_nicole.wav",
+ "synth_seconds": 2.248253800000384,
+ "transcribe_seconds": 0.9149532999981602
+ }
+]
\ No newline at end of file
diff --git a/examples/assorted_checks/test_transcription/output_long_form/long_form_report.json b/examples/assorted_checks/test_transcription/output_long_form/long_form_report.json
new file mode 100644
index 00000000..90412ac2
--- /dev/null
+++ b/examples/assorted_checks/test_transcription/output_long_form/long_form_report.json
@@ -0,0 +1,20 @@
+{
+ "voice": "af_heart",
+ "input_file": "input/journey_all.txt.gz",
+ "input_words": 11468,
+ "input_chars": 64996,
+ "input_chars_cap": 65000,
+ "audio_file": "output_long_form/long_form_af_heart.wav",
+ "audio_seconds": 3966.38,
+ "synth_seconds": 109.05,
+ "synth_realtime_factor": 36.37,
+ "audio_file_mb": 181.57,
+ "whisper_model": "base.en",
+ "whisper_device": "cuda",
+ "whisper_compute": "float16",
+ "transcribe_seconds": 63.57,
+ "transcribe_realtime_factor": 62.4,
+ "transcript_words": 11518,
+ "wer": 0.0466,
+ "transcript_file": "output_long_form/long_form_af_heart.transcript.txt"
+}
\ No newline at end of file
diff --git a/examples/assorted_checks/test_transcription/output_multilingual/report.json b/examples/assorted_checks/test_transcription/output_multilingual/report.json
new file mode 100644
index 00000000..46e2e926
--- /dev/null
+++ b/examples/assorted_checks/test_transcription/output_multilingual/report.json
@@ -0,0 +1,155 @@
+[
+ {
+ "voice": "af_heart",
+ "lang": "en",
+ "metric": "WER",
+ "expected": "The quick brown fox jumps over the lazy dog.",
+ "transcript": "The quick brown fox jumps over the lazy dog.",
+ "score": 0.0,
+ "threshold": 0.2,
+ "passed": true,
+ "detected_lang": "en",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual/af_heart_en.wav",
+ "audio_bytes": 138788,
+ "synth_seconds": 1.096960000002582,
+ "transcribe_seconds": 2.6913165000005392,
+ "error": ""
+ },
+ {
+ "voice": "bf_emma",
+ "lang": "en",
+ "metric": "WER",
+ "expected": "The rain in Spain falls mainly on the plain.",
+ "transcript": "The rain in Spain falls mainly on the plane.",
+ "score": 0.1111111111111111,
+ "threshold": 0.25,
+ "passed": true,
+ "detected_lang": "en",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual/bf_emma_en.wav",
+ "audio_bytes": 121672,
+ "synth_seconds": 1.1128214000018488,
+ "transcribe_seconds": 2.689043499998661,
+ "error": ""
+ },
+ {
+ "voice": "ef_dora",
+ "lang": "es",
+ "metric": "WER",
+ "expected": "El sol brilla en el cielo azul.",
+ "transcript": "El sol brilla en el cielo azul.",
+ "score": 0.0,
+ "threshold": 0.3,
+ "passed": true,
+ "detected_lang": "es",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual/ef_dora_es.wav",
+ "audio_bytes": 100522,
+ "synth_seconds": 0.37307390000205487,
+ "transcribe_seconds": 2.638828499999363,
+ "error": ""
+ },
+ {
+ "voice": "ff_siwis",
+ "lang": "fr",
+ "metric": "WER",
+ "expected": "Le soleil brille dans le ciel bleu.",
+ "transcript": "Le soleil brille dans le ciel bleu.",
+ "score": 0.0,
+ "threshold": 0.2,
+ "passed": true,
+ "detected_lang": "fr",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual/ff_siwis_fr.wav",
+ "audio_bytes": 95686,
+ "synth_seconds": 0.4655084000005445,
+ "transcribe_seconds": 2.7144860999978846,
+ "error": ""
+ },
+ {
+ "voice": "if_sara",
+ "lang": "it",
+ "metric": "WER",
+ "expected": "Il gatto dorme sul tappeto rosso.",
+ "transcript": "Il gatto dorme sul tappeto rosso.",
+ "score": 0.0,
+ "threshold": 0.3,
+ "passed": true,
+ "detected_lang": "it",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual/if_sara_it.wav",
+ "audio_bytes": 120668,
+ "synth_seconds": 0.47659769999881973,
+ "transcribe_seconds": 2.968207899997651,
+ "error": ""
+ },
+ {
+ "voice": "pf_dora",
+ "lang": "pt",
+ "metric": "WER",
+ "expected": "O gato dorme no tapete vermelho.",
+ "transcript": "O gato dorme no tapete vermelho.",
+ "score": 0.0,
+ "threshold": 0.3,
+ "passed": true,
+ "detected_lang": "pt",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual/pf_dora_pt.wav",
+ "audio_bytes": 112590,
+ "synth_seconds": 0.4205364000008558,
+ "transcribe_seconds": 2.666296700001112,
+ "error": ""
+ },
+ {
+ "voice": "hf_alpha",
+ "lang": "hi",
+ "metric": "CER",
+ "expected": "आज मौसम बहुत अच्छा है।",
+ "transcript": "आज मोसम बहुत अच्छा है",
+ "score": 0.058823529411764705,
+ "threshold": 0.2,
+ "passed": true,
+ "detected_lang": "hi",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual/hf_alpha_hi.wav",
+ "audio_bytes": 115704,
+ "synth_seconds": 0.43928260000029695,
+ "transcribe_seconds": 2.8490301000019826,
+ "error": ""
+ },
+ {
+ "voice": "jf_alpha",
+ "lang": "ja",
+ "metric": "CER",
+ "expected": "今日はとても良い天気です。",
+ "transcript": "今日はとても良い天気です",
+ "score": 0.0,
+ "threshold": 0.3,
+ "passed": true,
+ "detected_lang": "ja",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual/jf_alpha_ja.wav",
+ "audio_bytes": 99206,
+ "synth_seconds": 0.7097135999974853,
+ "transcribe_seconds": 2.6892165999997815,
+ "error": ""
+ },
+ {
+ "voice": "zf_xiaobei",
+ "lang": "zh",
+ "metric": "CER",
+ "expected": "今天天气非常好。",
+ "transcript": "今天天氣非常好",
+ "score": 0.14285714285714285,
+ "threshold": 0.25,
+ "passed": true,
+ "detected_lang": "zh",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual/zf_xiaobei_zh.wav",
+ "audio_bytes": 107382,
+ "synth_seconds": 2.063969900002121,
+ "transcribe_seconds": 2.705509899999015,
+ "error": ""
+ }
+]
\ No newline at end of file
diff --git a/examples/assorted_checks/test_transcription/output_multilingual_cpu_default/report.json b/examples/assorted_checks/test_transcription/output_multilingual_cpu_default/report.json
new file mode 100644
index 00000000..1eda6853
--- /dev/null
+++ b/examples/assorted_checks/test_transcription/output_multilingual_cpu_default/report.json
@@ -0,0 +1,155 @@
+[
+ {
+ "voice": "af_heart",
+ "lang": "en",
+ "metric": "WER",
+ "expected": "The quick brown fox jumps over the lazy dog.",
+ "transcript": "The quick brown fox jumps over the lazy dog.",
+ "score": 0.0,
+ "threshold": 0.2,
+ "passed": true,
+ "detected_lang": "en",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual_cpu_default/af_heart_en.wav",
+ "audio_bytes": 138812,
+ "synth_seconds": 2.298271300001943,
+ "transcribe_seconds": 2.8226972999982536,
+ "error": ""
+ },
+ {
+ "voice": "bf_emma",
+ "lang": "en",
+ "metric": "WER",
+ "expected": "The rain in Spain falls mainly on the plain.",
+ "transcript": "The rain in Spain falls mainly on the plane.",
+ "score": 0.1111111111111111,
+ "threshold": 0.25,
+ "passed": true,
+ "detected_lang": "en",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual_cpu_default/bf_emma_en.wav",
+ "audio_bytes": 121672,
+ "synth_seconds": 2.285058499997831,
+ "transcribe_seconds": 2.746085700004187,
+ "error": ""
+ },
+ {
+ "voice": "ef_dora",
+ "lang": "es",
+ "metric": "WER",
+ "expected": "El sol brilla en el cielo azul.",
+ "transcript": "El sol brilla en el cielo azul.",
+ "score": 0.0,
+ "threshold": 0.3,
+ "passed": true,
+ "detected_lang": "es",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual_cpu_default/ef_dora_es.wav",
+ "audio_bytes": 100522,
+ "synth_seconds": 1.2294228000027942,
+ "transcribe_seconds": 2.6340122999972664,
+ "error": ""
+ },
+ {
+ "voice": "ff_siwis",
+ "lang": "fr",
+ "metric": "WER",
+ "expected": "Le soleil brille dans le ciel bleu.",
+ "transcript": "Le soleil brille dans le ciel bleu.",
+ "score": 0.0,
+ "threshold": 0.2,
+ "passed": true,
+ "detected_lang": "fr",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual_cpu_default/ff_siwis_fr.wav",
+ "audio_bytes": 95684,
+ "synth_seconds": 1.0878834999966784,
+ "transcribe_seconds": 2.8614803000018583,
+ "error": ""
+ },
+ {
+ "voice": "if_sara",
+ "lang": "it",
+ "metric": "WER",
+ "expected": "Il gatto dorme sul tappeto rosso.",
+ "transcript": "Il gatto dorme sul tappeto rosso.",
+ "score": 0.0,
+ "threshold": 0.3,
+ "passed": true,
+ "detected_lang": "it",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual_cpu_default/if_sara_it.wav",
+ "audio_bytes": 120668,
+ "synth_seconds": 1.2805765000011888,
+ "transcribe_seconds": 3.6586665000068024,
+ "error": ""
+ },
+ {
+ "voice": "pf_dora",
+ "lang": "pt",
+ "metric": "WER",
+ "expected": "O gato dorme no tapete vermelho.",
+ "transcript": "O gato dorme no tapete vermelho.",
+ "score": 0.0,
+ "threshold": 0.3,
+ "passed": true,
+ "detected_lang": "pt",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual_cpu_default/pf_dora_pt.wav",
+ "audio_bytes": 112590,
+ "synth_seconds": 1.2797040000004927,
+ "transcribe_seconds": 3.0973912000044947,
+ "error": ""
+ },
+ {
+ "voice": "hf_alpha",
+ "lang": "hi",
+ "metric": "CER",
+ "expected": "आज मौसम बहुत अच्छा है।",
+ "transcript": "आज मोसम बहुत अच्छा हे",
+ "score": 0.11764705882352941,
+ "threshold": 0.2,
+ "passed": true,
+ "detected_lang": "hi",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual_cpu_default/hf_alpha_hi.wav",
+ "audio_bytes": 115594,
+ "synth_seconds": 1.5261495000013383,
+ "transcribe_seconds": 2.9073769000024186,
+ "error": ""
+ },
+ {
+ "voice": "jf_alpha",
+ "lang": "ja",
+ "metric": "CER",
+ "expected": "今日はとても良い天気です。",
+ "transcript": "今日はとても良い天気です",
+ "score": 0.0,
+ "threshold": 0.3,
+ "passed": true,
+ "detected_lang": "ja",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual_cpu_default/jf_alpha_ja.wav",
+ "audio_bytes": 99100,
+ "synth_seconds": 1.2916938999987906,
+ "transcribe_seconds": 2.5406462000028114,
+ "error": ""
+ },
+ {
+ "voice": "zf_xiaobei",
+ "lang": "zh",
+ "metric": "CER",
+ "expected": "今天天气非常好。",
+ "transcript": "今天天氣非常好",
+ "score": 0.14285714285714285,
+ "threshold": 0.25,
+ "passed": true,
+ "detected_lang": "zh",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual_cpu_default/zf_xiaobei_zh.wav",
+ "audio_bytes": 107298,
+ "synth_seconds": 2.9722165999992285,
+ "transcribe_seconds": 2.506729399996402,
+ "error": ""
+ }
+]
\ No newline at end of file
diff --git a/examples/assorted_checks/test_transcription/output_multilingual_cpu_noja/report.json b/examples/assorted_checks/test_transcription/output_multilingual_cpu_noja/report.json
new file mode 100644
index 00000000..ec547885
--- /dev/null
+++ b/examples/assorted_checks/test_transcription/output_multilingual_cpu_noja/report.json
@@ -0,0 +1,155 @@
+[
+ {
+ "voice": "af_heart",
+ "lang": "en",
+ "metric": "WER",
+ "expected": "The quick brown fox jumps over the lazy dog.",
+ "transcript": "The quick brown fox jumps over the lazy dog.",
+ "score": 0.0,
+ "threshold": 0.2,
+ "passed": true,
+ "detected_lang": "en",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual_cpu_noja/af_heart_en.wav",
+ "audio_bytes": 138830,
+ "synth_seconds": 2.211528299994825,
+ "transcribe_seconds": 2.905726799996046,
+ "error": ""
+ },
+ {
+ "voice": "bf_emma",
+ "lang": "en",
+ "metric": "WER",
+ "expected": "The rain in Spain falls mainly on the plain.",
+ "transcript": "The rain in Spain falls mainly on the plane.",
+ "score": 0.1111111111111111,
+ "threshold": 0.25,
+ "passed": true,
+ "detected_lang": "en",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual_cpu_noja/bf_emma_en.wav",
+ "audio_bytes": 121672,
+ "synth_seconds": 2.505469299998367,
+ "transcribe_seconds": 3.258189100000891,
+ "error": ""
+ },
+ {
+ "voice": "ef_dora",
+ "lang": "es",
+ "metric": "WER",
+ "expected": "El sol brilla en el cielo azul.",
+ "transcript": "El sol brilla en el cielo azul.",
+ "score": 0.0,
+ "threshold": 0.3,
+ "passed": true,
+ "detected_lang": "es",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual_cpu_noja/ef_dora_es.wav",
+ "audio_bytes": 100522,
+ "synth_seconds": 1.1369634000002407,
+ "transcribe_seconds": 3.1840576000031433,
+ "error": ""
+ },
+ {
+ "voice": "ff_siwis",
+ "lang": "fr",
+ "metric": "WER",
+ "expected": "Le soleil brille dans le ciel bleu.",
+ "transcript": "Le soleil brille dans le ciel bleu.",
+ "score": 0.0,
+ "threshold": 0.2,
+ "passed": true,
+ "detected_lang": "fr",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual_cpu_noja/ff_siwis_fr.wav",
+ "audio_bytes": 95690,
+ "synth_seconds": 1.2329757999978028,
+ "transcribe_seconds": 2.9903894000017317,
+ "error": ""
+ },
+ {
+ "voice": "if_sara",
+ "lang": "it",
+ "metric": "WER",
+ "expected": "Il gatto dorme sul tappeto rosso.",
+ "transcript": "Il gatto dorme sul tappeto rosso.",
+ "score": 0.0,
+ "threshold": 0.3,
+ "passed": true,
+ "detected_lang": "it",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual_cpu_noja/if_sara_it.wav",
+ "audio_bytes": 120670,
+ "synth_seconds": 1.3126929999998538,
+ "transcribe_seconds": 2.9695748000012827,
+ "error": ""
+ },
+ {
+ "voice": "pf_dora",
+ "lang": "pt",
+ "metric": "WER",
+ "expected": "O gato dorme no tapete vermelho.",
+ "transcript": "O gato dorme no tapete vermelho.",
+ "score": 0.0,
+ "threshold": 0.3,
+ "passed": true,
+ "detected_lang": "pt",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual_cpu_noja/pf_dora_pt.wav",
+ "audio_bytes": 112592,
+ "synth_seconds": 1.1745988999973633,
+ "transcribe_seconds": 3.2026891999994405,
+ "error": ""
+ },
+ {
+ "voice": "hf_alpha",
+ "lang": "hi",
+ "metric": "CER",
+ "expected": "आज मौसम बहुत अच्छा है।",
+ "transcript": "आज मोसम बहुत अच्छा हे",
+ "score": 0.11764705882352941,
+ "threshold": 0.2,
+ "passed": true,
+ "detected_lang": "hi",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual_cpu_noja/hf_alpha_hi.wav",
+ "audio_bytes": 115658,
+ "synth_seconds": 1.560672200001136,
+ "transcribe_seconds": 3.383672600000864,
+ "error": ""
+ },
+ {
+ "voice": "jf_alpha",
+ "lang": "ja",
+ "metric": "CER",
+ "expected": "今日はとても良い天気です。",
+ "transcript": "",
+ "score": 1.0,
+ "threshold": 0.3,
+ "passed": false,
+ "detected_lang": "",
+ "detected_prob": 0.0,
+ "audio_path": "output_multilingual_cpu_noja/jf_alpha_ja.wav",
+ "audio_bytes": 0,
+ "synth_seconds": 0.1126732999982778,
+ "transcribe_seconds": 0.0,
+ "error": "empty_audio (returned 0B WAV)"
+ },
+ {
+ "voice": "zf_xiaobei",
+ "lang": "zh",
+ "metric": "CER",
+ "expected": "今天天气非常好。",
+ "transcript": "今天天氣非常好",
+ "score": 0.14285714285714285,
+ "threshold": 0.25,
+ "passed": true,
+ "detected_lang": "zh",
+ "detected_prob": 1,
+ "audio_path": "output_multilingual_cpu_noja/zf_xiaobei_zh.wav",
+ "audio_bytes": 107296,
+ "synth_seconds": 3.156451199996809,
+ "transcribe_seconds": 3.1286701999997604,
+ "error": ""
+ }
+]
\ No newline at end of file
diff --git a/examples/assorted_checks/test_transcription/run_long_form.bat b/examples/assorted_checks/test_transcription/run_long_form.bat
new file mode 100644
index 00000000..c005d881
--- /dev/null
+++ b/examples/assorted_checks/test_transcription/run_long_form.bat
@@ -0,0 +1,62 @@
+@echo off
+REM Long-form runner. Usage: run_long_form.bat [short|full|synth|transcribe]
+REM short synth + transcribe on a 65k-char slice (~chapters 1-7) [default]
+REM full synth + transcribe on the whole book
+REM synth synth only, no transcription (fast on GPU)
+REM transcribe transcribe only, reuses the wav from a previous synth run
+REM Double-click runs 'short'. Output streams to the window and to a log file.
+
+setlocal
+set "MODE=%~1"
+if "%MODE%"=="" set "MODE=short"
+
+REM Whisper defaults; set these before invoking the .bat to override.
+if not defined WHISPER_DEVICE set "WHISPER_DEVICE=cuda"
+if not defined WHISPER_MODEL set "WHISPER_MODEL=base.en"
+
+set "EXTRA_ARGS="
+if /I "%MODE%"=="full" goto :full
+if /I "%MODE%"=="synth" goto :synth
+if /I "%MODE%"=="transcribe" goto :transcribe
+if /I "%MODE%"=="short" goto :short
+echo Unknown mode: %MODE%
+echo Usage: %~nx0 [full^|synth^|transcribe^|short]
+exit /b 2
+
+:full
+set "TAG=journey_all_full"
+goto :run
+:synth
+set "TAG=journey_all_synth"
+set "EXTRA_ARGS=--synth-only"
+goto :run
+:transcribe
+set "TAG=journey_all_transcribe"
+set "EXTRA_ARGS=--transcribe-only"
+goto :run
+:short
+set "TAG=journey_short_full"
+set "LONGFORM_CHARS=65000"
+goto :run
+
+:run
+set "SCRIPT_DIR=%~dp0"
+set "REPO_ROOT=%SCRIPT_DIR%..\..\.."
+set "LOG_DIR=%SCRIPT_DIR%output_long_form"
+set "LOG_FILE=%LOG_DIR%\%TAG%.log"
+set "LONGFORM_INPUT=examples/assorted_checks/test_transcription/input/journey_all.txt.gz"
+
+if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
+
+pushd "%REPO_ROOT%"
+powershell -NoProfile -ExecutionPolicy Bypass -Command ^
+ "$env:VIRTUAL_ENV = $null;" ^
+ "Write-Output ('Run started ' + (Get-Date)) | Tee-Object -FilePath '%LOG_FILE%';" ^
+ "uv run --project examples python examples/assorted_checks/test_transcription/test_long_form.py %EXTRA_ARGS% 2>&1 | Tee-Object -FilePath '%LOG_FILE%' -Append;" ^
+ "Write-Output ('Run finished ' + (Get-Date)) | Tee-Object -FilePath '%LOG_FILE%' -Append;"
+set RC=%ERRORLEVEL%
+popd
+
+echo.
+echo === DONE (mode=%MODE%, exit %RC%) -- log: %LOG_FILE%
+pause
diff --git a/examples/assorted_checks/test_transcription/test_long_form.py b/examples/assorted_checks/test_transcription/test_long_form.py
new file mode 100644
index 00000000..7c3b4fdb
--- /dev/null
+++ b/examples/assorted_checks/test_transcription/test_long_form.py
@@ -0,0 +1,353 @@
+"""Long-form roundtrip baseline.
+
+Synthesizes a long input file via a running Kokoro server, transcribes the
+result with faster-whisper, and reports timing + WER. Captures baseline
+numbers for multi-hour synthesis runs; not a pass/fail test.
+
+Usage (from repo root):
+ uv sync --project examples --extra transcription
+ uv run --project examples python examples/assorted_checks/test_transcription/test_long_form.py
+
+On Windows, run_long_form.bat wraps this with logging and named modes
+(short / full / synth / transcribe).
+
+Env overrides:
+ KOKORO_BASE_URL default http://localhost:8880/v1
+ LONGFORM_VOICE default af_heart
+ LONGFORM_INPUT default input/journey_all.txt.gz (relative to this script; .gz auto-decoded)
+ LONGFORM_CHARS default unset (full file); int caps cleaned input length
+ WHISPER_MODEL default base.en
+ WHISPER_DEVICE default cpu (set "cuda" for GPU)
+ WHISPER_COMPUTE default int8 on cpu, float16 on cuda
+"""
+
+from __future__ import annotations
+
+import argparse
+import gzip
+import json
+import os
+import re
+import sys
+import time
+from pathlib import Path
+
+import openai
+
+
+BASE_URL = os.environ.get("KOKORO_BASE_URL", "http://localhost:8880/v1")
+VOICE = os.environ.get("LONGFORM_VOICE", "af_heart")
+WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "base.en")
+WHISPER_DEVICE = os.environ.get("WHISPER_DEVICE", "cpu").lower()
+WHISPER_COMPUTE = os.environ.get(
+ "WHISPER_COMPUTE", "float16" if WHISPER_DEVICE == "cuda" else "int8"
+)
+
+SCRIPT_DIR = Path(__file__).parent
+INPUT_PATH = Path(os.environ.get("LONGFORM_INPUT", SCRIPT_DIR / "input" / "journey_all.txt.gz"))
+OUTPUT_DIR = SCRIPT_DIR / "output_long_form"
+
+
+def rel(path: Path) -> str:
+ """Path relative to the script dir, posix-style."""
+ return path.resolve().relative_to(SCRIPT_DIR.resolve()).as_posix()
+
+
+def _build_normalizer():
+ # Imported lazily so --synth-only runs don't require faster-whisper/jiwer.
+ from jiwer.transforms import (
+ Compose,
+ ReduceToListOfListOfWords,
+ RemoveMultipleSpaces,
+ RemovePunctuation,
+ Strip,
+ SubstituteWords,
+ ToLowerCase,
+ )
+
+ digit_words = {
+ "0": "zero", "1": "one", "2": "two", "3": "three", "4": "four",
+ "5": "five", "6": "six", "7": "seven", "8": "eight", "9": "nine",
+ "10": "ten", "11": "eleven", "12": "twelve",
+ }
+ return Compose([
+ ToLowerCase(),
+ RemovePunctuation(),
+ SubstituteWords(digit_words),
+ RemoveMultipleSpaces(),
+ Strip(),
+ ReduceToListOfListOfWords(),
+ ])
+
+
+def clean_input(raw: str) -> str:
+ # Strip HTML/XML-style tags ("i.e." etc.) and collapse whitespace.
+ no_tags = re.sub(r"<[^>]+>", "", raw)
+ return re.sub(r"\s+", " ", no_tags).strip()
+
+
+def fmt_time(seconds: float) -> str:
+ if seconds < 60:
+ return f"{seconds:.1f}s"
+ m, s = divmod(int(seconds), 60)
+ return f"{m}m{s:02d}s"
+
+
+def synthesize_streaming(client: openai.OpenAI, text: str, out_path: Path) -> float:
+ start = time.perf_counter()
+ with client.audio.speech.with_streaming_response.create(
+ model="tts-1",
+ voice=VOICE,
+ input=text,
+ response_format="wav",
+ ) as response:
+ with open(out_path, "wb") as f:
+ for chunk in response.iter_bytes(chunk_size=64 * 1024):
+ f.write(chunk)
+ fix_streaming_wav_header(out_path)
+ return time.perf_counter() - start
+
+
+def fix_streaming_wav_header(wav_path: Path) -> None:
+ # Streaming WAV responses stamp placeholder sizes (RIFF=0xFFFFFFFF, data
+ # chunk size near 0) because the server doesn't know the total length up
+ # front. Rewrite both fields in place so downstream tools see real sizes.
+ import struct
+
+ size = wav_path.stat().st_size
+ with open(wav_path, "r+b") as f:
+ header = f.read(12)
+ if len(header) < 12 or header[:4] != b"RIFF" or header[8:12] != b"WAVE":
+ return # not a RIFF/WAVE file, leave it alone
+
+ # Walk subchunks until we find 'data', record its size offset.
+ data_size_offset = None
+ while True:
+ chunk_header = f.read(8)
+ if len(chunk_header) < 8:
+ break
+ chunk_id = chunk_header[:4]
+ chunk_size = struct.unpack(" float:
+ import wave
+
+ with wave.open(str(wav_path), "rb") as wf:
+ return wf.getnframes() / float(wf.getframerate())
+
+
+def transcribe(model, audio_path: Path, total_audio_s: float | None = None) -> tuple[str, float]:
+ start = time.perf_counter()
+ segments, _info = model.transcribe(
+ str(audio_path),
+ beam_size=1,
+ vad_filter=True,
+ )
+ parts: list[str] = []
+ last_print = 0.0
+ for seg in segments:
+ parts.append(seg.text)
+ # Heartbeat every ~30s of audio processed so long runs aren't silent.
+ if seg.end - last_print >= 30.0:
+ elapsed = time.perf_counter() - start
+ if total_audio_s:
+ pct = 100.0 * seg.end / total_audio_s
+ print(f" [{fmt_time(elapsed)}] {fmt_time(seg.end)} / {fmt_time(total_audio_s)} ({pct:.0f}%)", flush=True)
+ else:
+ print(f" [{fmt_time(elapsed)}] {fmt_time(seg.end)}", flush=True)
+ last_print = seg.end
+ text = " ".join(parts).strip()
+ return text, time.perf_counter() - start
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--synth-only",
+ action="store_true",
+ help="Generate audio and stop. Skip Whisper load and transcription.",
+ )
+ parser.add_argument(
+ "--transcribe-only",
+ action="store_true",
+ help="Skip synthesis and transcribe the existing wav from a previous run.",
+ )
+ parser.add_argument(
+ "--chars",
+ type=int,
+ default=None,
+ help="Cap cleaned input at N chars (cuts at the last whitespace before N). "
+ "Default: full file. Also reads LONGFORM_CHARS.",
+ )
+ args = parser.parse_args()
+ if args.synth_only and args.transcribe_only:
+ parser.error("--synth-only and --transcribe-only are mutually exclusive")
+
+ char_cap = args.chars
+ if char_cap is None and os.environ.get("LONGFORM_CHARS"):
+ char_cap = int(os.environ["LONGFORM_CHARS"])
+
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+
+ audio_path = OUTPUT_DIR / f"long_form_{VOICE}.wav"
+ synth_meta_path = OUTPUT_DIR / f"long_form_{VOICE}.synth_meta.json"
+
+ # Transcribe-only inherits the exact char cap the synth used, so the
+ # reference text matches what's actually in the wav.
+ if args.transcribe_only and char_cap is None and synth_meta_path.exists():
+ try:
+ inherited = json.loads(synth_meta_path.read_text()).get("input_chars_cap")
+ if inherited is not None:
+ char_cap = int(inherited)
+ print(f"Inheriting char cap from prior synth: {char_cap}")
+ except Exception:
+ pass
+
+ if INPUT_PATH.suffix == ".gz":
+ with gzip.open(INPUT_PATH, "rt", encoding="utf-8") as f:
+ raw = f.read()
+ else:
+ raw = INPUT_PATH.read_text(encoding="utf-8")
+ text = clean_input(raw)
+ full_chars = len(text)
+ if char_cap is not None and char_cap < full_chars:
+ cut = text.rfind(" ", 0, char_cap)
+ text = text[: cut if cut > 0 else char_cap]
+ word_count = len(text.split())
+ char_count = len(text)
+
+ print(f"Server: {BASE_URL}")
+ print(f"Voice: {VOICE}")
+ if char_cap is not None and char_count < full_chars:
+ print(f"Input: {INPUT_PATH.name} ({word_count} words, {char_count} chars; capped at {char_cap} of {full_chars})")
+ else:
+ print(f"Input: {INPUT_PATH.name} ({word_count} words, {char_count} chars)")
+ print(f"Whisper: {WHISPER_MODEL} ({WHISPER_DEVICE}, {WHISPER_COMPUTE}, VAD on)")
+ print()
+
+ if args.transcribe_only:
+ if not audio_path.exists():
+ print(f"--transcribe-only set but no audio at {audio_path}")
+ return 2
+ synth_s = 0.0
+ audio_s = audio_duration_seconds(audio_path)
+ synth_rtf = 0.0
+ file_mb = audio_path.stat().st_size / (1024 * 1024)
+ print(f"Reusing existing audio ({fmt_time(audio_s)}, {file_mb:.1f} MB)")
+ print()
+ else:
+ client = openai.OpenAI(base_url=BASE_URL, api_key="not-needed", timeout=None)
+
+ print("Synthesizing...")
+ try:
+ synth_s = synthesize_streaming(client, text, audio_path)
+ except Exception as exc:
+ print(f" synthesis failed after {time.perf_counter():.1f}s: {exc}")
+ return 2
+
+ audio_s = audio_duration_seconds(audio_path)
+ synth_rtf = audio_s / synth_s if synth_s > 0 else 0.0
+ file_mb = audio_path.stat().st_size / (1024 * 1024)
+ synth_meta_path.write_text(json.dumps({
+ "input_file": rel(INPUT_PATH),
+ "input_chars_cap": char_cap,
+ "input_chars": char_count,
+ "voice": VOICE,
+ }, indent=2))
+ print(f" synth time: {fmt_time(synth_s)}")
+ print(f" audio length: {fmt_time(audio_s)}")
+ print(f" synth speedup: {synth_rtf:.2f}x realtime")
+ print(f" file size: {file_mb:.1f} MB ({rel(audio_path)})")
+ print()
+
+ report = {
+ "voice": VOICE,
+ "input_file": rel(INPUT_PATH),
+ "input_words": word_count,
+ "input_chars": char_count,
+ "input_chars_cap": char_cap,
+ "audio_file": rel(audio_path),
+ "audio_seconds": round(audio_s, 2),
+ "synth_seconds": round(synth_s, 2),
+ "synth_realtime_factor": round(synth_rtf, 2),
+ "audio_file_mb": round(file_mb, 2),
+ }
+
+ if args.synth_only:
+ report_path = OUTPUT_DIR / "long_form_report.json"
+ report_path.write_text(json.dumps(report, indent=2))
+ print("Summary (synth-only)")
+ print(f" generated {fmt_time(audio_s)} of audio in {fmt_time(synth_s)} ({synth_rtf:.1f}x rt)")
+ print(f" audio: {rel(audio_path)}")
+ print(f" report: {rel(report_path)}")
+ return 0
+
+ from faster_whisper import WhisperModel
+ from jiwer import wer
+
+ normalize = _build_normalizer()
+
+ print(f"Loading Whisper model ({WHISPER_DEVICE}, {WHISPER_COMPUTE})...")
+ model = WhisperModel(WHISPER_MODEL, device=WHISPER_DEVICE, compute_type=WHISPER_COMPUTE)
+
+ print("Transcribing...")
+ transcript, trans_s = transcribe(model, audio_path, audio_s)
+ trans_rtf = audio_s / trans_s if trans_s > 0 else 0.0
+ print(f" transcribe time: {fmt_time(trans_s)}")
+ print(f" transcribe speedup: {trans_rtf:.2f}x realtime")
+ print(f" transcript words: {len(transcript.split())}")
+ print()
+
+ score = wer(
+ text,
+ transcript,
+ reference_transform=normalize,
+ hypothesis_transform=normalize,
+ )
+ print(f"WER (normalized): {score:.4f}")
+ print()
+
+ transcript_path = OUTPUT_DIR / f"long_form_{VOICE}.transcript.txt"
+ transcript_path.write_text(transcript, encoding="utf-8")
+
+ report.update({
+ "whisper_model": WHISPER_MODEL,
+ "whisper_device": WHISPER_DEVICE,
+ "whisper_compute": WHISPER_COMPUTE,
+ "transcribe_seconds": round(trans_s, 2),
+ "transcribe_realtime_factor": round(trans_rtf, 2),
+ "transcript_words": len(transcript.split()),
+ "wer": round(score, 4),
+ "transcript_file": rel(transcript_path),
+ })
+ report_path = OUTPUT_DIR / "long_form_report.json"
+ report_path.write_text(json.dumps(report, indent=2))
+
+ print("Summary")
+ if synth_s > 0:
+ print(f" generated {fmt_time(audio_s)} of audio in {fmt_time(synth_s)} ({synth_rtf:.1f}x rt)")
+ else:
+ print(f" reused {fmt_time(audio_s)} of audio (no resynth)")
+ print(f" transcribed in {fmt_time(trans_s)} ({trans_rtf:.1f}x rt)")
+ print(f" WER {score:.4f} vs cleaned input")
+ print(f" report: {rel(report_path)}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/examples/assorted_checks/test_transcription/test_transcription.py b/examples/assorted_checks/test_transcription/test_transcription.py
new file mode 100644
index 00000000..2dded495
--- /dev/null
+++ b/examples/assorted_checks/test_transcription/test_transcription.py
@@ -0,0 +1,175 @@
+"""TTS roundtrip validation.
+
+Synthesizes short phrases via a running Kokoro server, transcribes the audio
+locally with faster-whisper, and reports word error rate against the expected
+text. Intended as a manual sanity check, not a pytest suite.
+
+Usage (from repo root):
+ uv sync --project examples --extra transcription
+ uv run --project examples python examples/assorted_checks/test_transcription/test_transcription.py
+
+Env overrides:
+ KOKORO_BASE_URL default http://localhost:8880/v1
+ WHISPER_MODEL default base.en
+ WER_THRESHOLD default 0.2
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+import time
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+import openai
+from faster_whisper import WhisperModel
+from jiwer import wer
+from jiwer.transforms import (
+ Compose,
+ RemoveMultipleSpaces,
+ RemovePunctuation,
+ ReduceToListOfListOfWords,
+ Strip,
+ SubstituteWords,
+ ToLowerCase,
+)
+
+
+# Whisper often writes small numbers as digits ("5") even when the prompt was
+# spelled out ("five"). Normalize both directions before computing WER so the
+# metric reflects pronunciation accuracy, not formatting.
+_DIGIT_WORDS = {
+ "0": "zero", "1": "one", "2": "two", "3": "three", "4": "four",
+ "5": "five", "6": "six", "7": "seven", "8": "eight", "9": "nine",
+ "10": "ten", "11": "eleven", "12": "twelve",
+}
+
+_NORMALIZE = Compose([
+ ToLowerCase(),
+ RemovePunctuation(),
+ SubstituteWords(_DIGIT_WORDS),
+ RemoveMultipleSpaces(),
+ Strip(),
+ ReduceToListOfListOfWords(),
+])
+
+
+def _normalized_wer(reference: str, hypothesis: str) -> float:
+ return wer(
+ reference,
+ hypothesis,
+ reference_transform=_NORMALIZE,
+ hypothesis_transform=_NORMALIZE,
+ )
+
+
+BASE_URL = os.environ.get("KOKORO_BASE_URL", "http://localhost:8880/v1")
+WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "base.en")
+WER_THRESHOLD = float(os.environ.get("WER_THRESHOLD", "0.2"))
+
+SCRIPT_DIR = Path(__file__).parent
+OUTPUT_DIR = SCRIPT_DIR / "output"
+
+
+def rel(path: Path) -> str:
+ """Path relative to the script dir, posix-style."""
+ return path.resolve().relative_to(SCRIPT_DIR.resolve()).as_posix()
+
+CASES: list[tuple[str, str]] = [
+ ("af_heart", "The quick brown fox jumps over the lazy dog."),
+ ("af_bella", "She sells seashells by the seashore."),
+ ("am_adam", "Pack my box with five dozen liquor jugs."),
+ ("af_nicole", "The five boxing wizards jump quickly across the field."),
+]
+
+
+@dataclass
+class Result:
+ voice: str
+ expected: str
+ transcript: str
+ wer: float
+ passed: bool
+ audio_path: str
+ synth_seconds: float
+ transcribe_seconds: float
+
+
+def synthesize(client: openai.OpenAI, voice: str, text: str, out_path: Path) -> float:
+ start = time.perf_counter()
+ response = client.audio.speech.create(
+ model="tts-1",
+ voice=voice,
+ input=text,
+ response_format="wav",
+ )
+ out_path.write_bytes(response.content)
+ return time.perf_counter() - start
+
+
+def transcribe(model: WhisperModel, audio_path: Path) -> tuple[str, float]:
+ start = time.perf_counter()
+ segments, _info = model.transcribe(str(audio_path), beam_size=1)
+ text = " ".join(seg.text for seg in segments).strip()
+ return text, time.perf_counter() - start
+
+
+def main() -> int:
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+
+ print(f"Server: {BASE_URL}")
+ print(f"Whisper: {WHISPER_MODEL} (CPU, int8)")
+ print(f"Threshold: WER < {WER_THRESHOLD}")
+ print()
+
+ client = openai.OpenAI(base_url=BASE_URL, api_key="not-needed", timeout=60)
+
+ print("Loading Whisper model (first run downloads ~150MB)...")
+ model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")
+ print("Loaded.\n")
+
+ results: list[Result] = []
+ for voice, expected in CASES:
+ audio_path = OUTPUT_DIR / f"{voice}.wav"
+ print(f"[{voice}] {expected!r}")
+ try:
+ synth_s = synthesize(client, voice, expected, audio_path)
+ except Exception as exc:
+ print(f" synthesis failed: {exc}\n")
+ continue
+
+ transcript, trans_s = transcribe(model, audio_path)
+ score = _normalized_wer(expected, transcript)
+ passed = score < WER_THRESHOLD
+
+ print(f" heard: {transcript!r}")
+ print(f" WER: {score:.3f} ({'PASS' if passed else 'FAIL'})")
+ print(f" timing: synth {synth_s:.2f}s, transcribe {trans_s:.2f}s\n")
+
+ results.append(
+ Result(
+ voice=voice,
+ expected=expected,
+ transcript=transcript,
+ wer=score,
+ passed=passed,
+ audio_path=rel(audio_path),
+ synth_seconds=synth_s,
+ transcribe_seconds=trans_s,
+ )
+ )
+
+ report_path = OUTPUT_DIR / "report.json"
+ report_path.write_text(json.dumps([asdict(r) for r in results], indent=2))
+
+ total = len(results)
+ passed_count = sum(1 for r in results if r.passed)
+ print(f"Summary: {passed_count}/{total} passed. Report: {rel(report_path)}")
+
+ return 0 if results and passed_count == total else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/examples/assorted_checks/test_transcription/test_transcription_multilingual.py b/examples/assorted_checks/test_transcription/test_transcription_multilingual.py
new file mode 100644
index 00000000..03b6397e
--- /dev/null
+++ b/examples/assorted_checks/test_transcription/test_transcription_multilingual.py
@@ -0,0 +1,244 @@
+"""Multilingual TTS roundtrip validation.
+
+Same idea as test_transcription.py, but with multilingual Whisper and
+per-language reference phrases. CER for ja/zh (no word boundaries), WER for
+everything else. Intended as a manual sanity check across Kokoro's non-English
+voices — especially Japanese, which fails quietly often enough to want a
+dedicated harness.
+
+Usage (from repo root):
+ uv sync --project examples --extra transcription
+ uv run --project examples python examples/assorted_checks/test_transcription/test_transcription_multilingual.py
+
+Env overrides:
+ KOKORO_BASE_URL default http://localhost:8880/v1
+ WHISPER_MODEL default small (multilingual; ~470MB int8)
+ WHISPER_DEVICE default cpu
+ WHISPER_COMPUTE default int8 on cpu, float16 on cuda
+
+Note on silent failures: when Kokoro emits silence/garbage, Whisper sometimes
+hallucinates a canned phrase under a forced language hint (e.g. Japanese →
+"ありがとうございました"). The score will still be poor; just don't be surprised
+when the transcript reads as something the audio doesn't say.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+import time
+import unicodedata
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+# Windows stdout defaults to cp1252 — re-encode so Devanagari / CJK
+# reference strings and transcripts print without crashing.
+if hasattr(sys.stdout, "reconfigure"):
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
+
+import openai
+from faster_whisper import WhisperModel
+from jiwer import cer, wer
+from jiwer.transforms import (
+ Compose,
+ ReduceToListOfListOfWords,
+ RemoveMultipleSpaces,
+ RemovePunctuation,
+ Strip,
+ ToLowerCase,
+)
+
+
+BASE_URL = os.environ.get("KOKORO_BASE_URL", "http://localhost:8880/v1")
+WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "small")
+WHISPER_DEVICE = os.environ.get("WHISPER_DEVICE", "cpu")
+WHISPER_COMPUTE = os.environ.get(
+ "WHISPER_COMPUTE", "int8" if WHISPER_DEVICE == "cpu" else "float16"
+)
+
+SCRIPT_DIR = Path(__file__).parent
+OUTPUT_DIR = SCRIPT_DIR / "output_multilingual"
+
+
+# (voice, whisper ISO lang code, expected text, pass threshold)
+# CJK uses CER, the rest WER. Thresholds are first-pass and deliberately
+# loose — tighten after we see what `small` actually delivers.
+CASES: list[tuple[str, str, str, float]] = [
+ ("af_heart", "en", "The quick brown fox jumps over the lazy dog.", 0.20),
+ ("bf_emma", "en", "The rain in Spain falls mainly on the plain.", 0.25),
+ ("ef_dora", "es", "El sol brilla en el cielo azul.", 0.30),
+ ("ff_siwis", "fr", "Le soleil brille dans le ciel bleu.", 0.20),
+ ("if_sara", "it", "Il gatto dorme sul tappeto rosso.", 0.30),
+ ("pf_dora", "pt", "O gato dorme no tapete vermelho.", 0.30),
+ ("hf_alpha", "hi", "आज मौसम बहुत अच्छा है।", 0.20),
+ ("jf_alpha", "ja", "今日はとても良い天気です。", 0.30),
+ ("zf_xiaobei", "zh", "今天天气非常好。", 0.25),
+]
+
+# Hindi uses combining diacritics that make single-vowel mistakes count as
+# whole-word substitutions under WER; CER is the right metric for any script
+# where character-level diffs dominate, not just CJK.
+CER_LANGS = {"hi", "ja", "zh"}
+
+
+_WORD_NORMALIZE = Compose([
+ ToLowerCase(),
+ RemovePunctuation(),
+ RemoveMultipleSpaces(),
+ Strip(),
+ ReduceToListOfListOfWords(),
+])
+
+
+def _strip_for_cer(s: str) -> str:
+ return "".join(
+ c for c in s
+ if not c.isspace() and not unicodedata.category(c).startswith("P")
+ )
+
+
+def _score(lang: str, reference: str, hypothesis: str) -> float:
+ if lang in CER_LANGS:
+ return cer(_strip_for_cer(reference), _strip_for_cer(hypothesis))
+ return wer(
+ reference,
+ hypothesis,
+ reference_transform=_WORD_NORMALIZE,
+ hypothesis_transform=_WORD_NORMALIZE,
+ )
+
+
+@dataclass
+class Result:
+ voice: str
+ lang: str
+ metric: str
+ expected: str
+ transcript: str
+ score: float
+ threshold: float
+ passed: bool
+ detected_lang: str
+ detected_prob: float
+ audio_path: str
+ audio_bytes: int
+ synth_seconds: float
+ transcribe_seconds: float
+ error: str = ""
+
+
+def rel(path: Path) -> str:
+ return path.resolve().relative_to(SCRIPT_DIR.resolve()).as_posix()
+
+
+def synthesize(client: openai.OpenAI, voice: str, text: str, out_path: Path) -> float:
+ start = time.perf_counter()
+ response = client.audio.speech.create(
+ model="tts-1",
+ voice=voice,
+ input=text,
+ response_format="wav",
+ )
+ out_path.write_bytes(response.content)
+ return time.perf_counter() - start
+
+
+def transcribe(
+ model: WhisperModel, audio_path: Path, language: str
+) -> tuple[str, str, float, float]:
+ start = time.perf_counter()
+ segments, info = model.transcribe(
+ str(audio_path), beam_size=1, language=language
+ )
+ text = " ".join(seg.text for seg in segments).strip()
+ return text, info.language, info.language_probability, time.perf_counter() - start
+
+
+def main() -> int:
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+
+ print(f"Server: {BASE_URL}")
+ print(f"Whisper: {WHISPER_MODEL} ({WHISPER_DEVICE}, {WHISPER_COMPUTE})")
+ print()
+
+ client = openai.OpenAI(base_url=BASE_URL, api_key="not-needed", timeout=120)
+
+ print(f"Loading Whisper model {WHISPER_MODEL!r} (first run downloads weights)...")
+ model = WhisperModel(WHISPER_MODEL, device=WHISPER_DEVICE, compute_type=WHISPER_COMPUTE)
+ print("Loaded.\n")
+
+ results: list[Result] = []
+ for voice, lang, expected, threshold in CASES:
+ audio_path = OUTPUT_DIR / f"{voice}_{lang}.wav"
+ metric = "CER" if lang in CER_LANGS else "WER"
+ print(f"[{voice} / {lang}] {expected!r}")
+
+ def _fail(error: str, synth_s: float = 0.0, trans_s: float = 0.0) -> None:
+ audio_bytes = audio_path.stat().st_size if audio_path.exists() else 0
+ print(f" FAIL ({error}) audio={audio_bytes}B\n")
+ results.append(
+ Result(
+ voice=voice, lang=lang, metric=metric, expected=expected,
+ transcript="", score=1.0, threshold=threshold, passed=False,
+ detected_lang="", detected_prob=0.0,
+ audio_path=rel(audio_path), audio_bytes=audio_bytes,
+ synth_seconds=synth_s, transcribe_seconds=trans_s,
+ error=error,
+ )
+ )
+
+ try:
+ synth_s = synthesize(client, voice, expected, audio_path)
+ except Exception as exc:
+ _fail(f"synth_error: {exc}")
+ continue
+
+ audio_bytes = audio_path.stat().st_size
+ # Catches Kokoro's silent-fail mode (notably Japanese): the HTTP call
+ # returns 200 with an empty/near-empty body, so we never see an
+ # exception — only a zero-byte WAV that downstream tooling chokes on.
+ if audio_bytes < 100:
+ _fail(f"empty_audio (returned {audio_bytes}B WAV)", synth_s=synth_s)
+ continue
+
+ try:
+ transcript, det_lang, det_prob, trans_s = transcribe(model, audio_path, lang)
+ except Exception as exc:
+ _fail(f"transcribe_error: {exc}", synth_s=synth_s)
+ continue
+
+ score = _score(lang, expected, transcript)
+ passed = score < threshold
+
+ print(f" heard: {transcript!r}")
+ print(f" detected: lang={det_lang} (p={det_prob:.2f})")
+ print(f" {metric}: {score:.3f} threshold {threshold} ({'PASS' if passed else 'FAIL'})")
+ print(f" timing: synth {synth_s:.2f}s, transcribe {trans_s:.2f}s\n")
+
+ results.append(
+ Result(
+ voice=voice, lang=lang, metric=metric, expected=expected,
+ transcript=transcript, score=score, threshold=threshold, passed=passed,
+ detected_lang=det_lang, detected_prob=det_prob,
+ audio_path=rel(audio_path), audio_bytes=audio_bytes,
+ synth_seconds=synth_s, transcribe_seconds=trans_s,
+ )
+ )
+
+ report_path = OUTPUT_DIR / "report.json"
+ report_path.write_text(
+ json.dumps([asdict(r) for r in results], indent=2, ensure_ascii=False),
+ encoding="utf-8",
+ )
+
+ total = len(results)
+ passed_count = sum(1 for r in results if r.passed)
+ print(f"Summary: {passed_count}/{total} passed. Report: {rel(report_path)}")
+
+ return 0 if results and passed_count == total else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/examples/assorted_checks/validate_wav.py b/examples/assorted_checks/validate_wav.py
index 844655ae..a2c81e2c 100644
--- a/examples/assorted_checks/validate_wav.py
+++ b/examples/assorted_checks/validate_wav.py
@@ -248,7 +248,7 @@ def generate_analysis_plots(
if __name__ == "__main__":
- wav_file = r"C:\Users\jerem\Desktop\Kokoro-FastAPI\examples\assorted_checks\benchmarks\output_audio\chunk_600_tokens.wav"
+ wav_file = str(Path(__file__).parent / "benchmarks" / "output_audio" / "chunk_600_tokens.wav")
silent = False
print(f"\n\n Processing:\n\t{wav_file}")
diff --git a/examples/pyproject.toml b/examples/pyproject.toml
new file mode 100644
index 00000000..4932ffd6
--- /dev/null
+++ b/examples/pyproject.toml
@@ -0,0 +1,37 @@
+[project]
+name = "kokoro-examples"
+version = "0.1.0"
+description = "Standalone scripts and validation tools for Kokoro-FastAPI."
+requires-python = ">=3.10"
+dependencies = [
+ "openai>=1.0.0",
+ "numpy>=1.24",
+ "scipy>=1.10",
+ "loguru>=0.7",
+ "tqdm>=4.65",
+ "requests>=2.33.1",
+]
+
+[project.optional-dependencies]
+playback = [
+ "pyaudio>=0.2.13",
+]
+benchmarks = [
+ "matplotlib>=3.7",
+ "seaborn>=0.13",
+ "pandas>=2.0",
+ "psutil>=5.9",
+ "tiktoken>=0.5",
+]
+transcription = [
+ "faster-whisper>=1.0.0",
+ "hf-transfer>=0.1.9",
+ "jiwer>=3.0.0",
+]
+transcription-gpu = [
+ "nvidia-cublas-cu12>=12.9.2.10",
+ "nvidia-cudnn-cu12>=9.22.0.52",
+]
+
+[tool.uv]
+package = false
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 00000000..e62461e6
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,75 @@
+{
+ "name": "Kokoro-FastAPI",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "devDependencies": {
+ "@playwright/test": "^1.57.0"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
+ "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.60.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
+ "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.60.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
+ "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..d57c075f
--- /dev/null
+++ b/package.json
@@ -0,0 +1,19 @@
+{
+ "type": "module",
+ "scripts": {
+ "test:web": "node web/tests/unit/index.test.mjs",
+ "test:e2e": "playwright test",
+ "gpu:build": "docker compose -f docker/gpu/docker-compose.yml build",
+ "gpu:up": "docker compose -f docker/gpu/docker-compose.yml up",
+ "gpu:down": "docker compose -f docker/gpu/docker-compose.yml down",
+ "cpu:build": "docker compose -f docker/cpu/docker-compose.yml build",
+ "cpu:up": "docker compose -f docker/cpu/docker-compose.yml up",
+ "cpu:down": "docker compose -f docker/cpu/docker-compose.yml down",
+ "rocm:build": "docker compose -f docker/rocm/docker-compose.yml build",
+ "rocm:up": "docker compose -f docker/rocm/docker-compose.yml up",
+ "rocm:down": "docker compose -f docker/rocm/docker-compose.yml down"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.57.0"
+ }
+}
diff --git a/playwright.config.mjs b/playwright.config.mjs
new file mode 100644
index 00000000..3e6c8e63
--- /dev/null
+++ b/playwright.config.mjs
@@ -0,0 +1,15 @@
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './web/tests/e2e',
+ timeout: 30_000,
+ use: {
+ baseURL: 'http://127.0.0.1:4173',
+ },
+ webServer: {
+ command: 'node web/tests/e2e/fixtures/static-server.mjs',
+ url: 'http://127.0.0.1:4173',
+ reuseExistingServer: true,
+ timeout: 10_000,
+ },
+});
diff --git a/pyproject.toml b/pyproject.toml
index 97e38f80..fd506ee5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "kokoro-fastapi"
-version = "0.3.0"
+version = "0.6.0"
description = "FastAPI TTS Service"
readme = "README.md"
requires-python = ">=3.10"
@@ -43,7 +43,17 @@ dependencies = [
]
[project.optional-dependencies]
-gpu = ["torch==2.8.0+cu129"]
+gpu = [
+ "torch==2.8.0+cu126 ; platform_machine == 'x86_64'",
+ "torch==2.8.0+cu129 ; platform_machine == 'aarch64'",
+]
+# Blackwell / RTX 50-series variant. cu128 wheels carry sm_120 kernels but
+# drop Maxwell/Pascal, so this is a separate extra rather than the default.
+# See #443. aarch64 already ships cu129 (Blackwell-capable) under both extras.
+gpu-cu128 = [
+ "torch==2.8.0+cu128 ; platform_machine == 'x86_64'",
+ "torch==2.8.0+cu129 ; platform_machine == 'aarch64'",
+]
cpu = ["torch==2.8.0"]
rocm = [
"torch==2.8.0+rocm6.4",
@@ -57,12 +67,17 @@ test = [
"tomli>=2.0.1",
"jinja2>=3.1.6",
]
+# Integration test deps (faster-whisper, jiwer, soundfile, etc.) intentionally
+# live in the tts-api-test-client image, not here. Run via:
+# docker compose -f docker/docker-compose.test.yml up --build \
+# --abort-on-container-exit --exit-code-from test-client
[tool.uv]
conflicts = [
[
{ extra = "cpu" },
{ extra = "gpu" },
+ { extra = "gpu-cu128" },
{ extra = "rocm" },
],
]
@@ -73,7 +88,10 @@ override-dependencies = [
[tool.uv.sources]
torch = [
{ index = "pytorch-cpu", extra = "cpu" },
- { index = "pytorch-cuda", extra = "gpu" },
+ { index = "pytorch-cu126", extra = "gpu", marker = "platform_machine == 'x86_64'" },
+ { index = "pytorch-cu129", extra = "gpu", marker = "platform_machine == 'aarch64'" },
+ { index = "pytorch-cu128", extra = "gpu-cu128", marker = "platform_machine == 'x86_64'" },
+ { index = "pytorch-cu129", extra = "gpu-cu128", marker = "platform_machine == 'aarch64'" },
{ index = "pytorch-rocm", extra = "rocm" },
]
pytorch-triton-rocm = [
@@ -86,7 +104,17 @@ url = "https://download.pytorch.org/whl/cpu"
explicit = true
[[tool.uv.index]]
-name = "pytorch-cuda"
+name = "pytorch-cu126"
+url = "https://download.pytorch.org/whl/cu126"
+explicit = true
+
+[[tool.uv.index]]
+name = "pytorch-cu128"
+url = "https://download.pytorch.org/whl/cu128"
+explicit = true
+
+[[tool.uv.index]]
+name = "pytorch-cu129"
url = "https://download.pytorch.org/whl/cu129"
explicit = true
diff --git a/pytest.ini b/pytest.ini
index 3bcd461b..5ebb50eb 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,5 +1,7 @@
[pytest]
testpaths = api/tests
python_files = test_*.py
-addopts = -v --tb=short --cov=api --cov-report=term-missing --cov-config=.coveragerc
+addopts = -v --tb=short --cov=api --cov-report=term-missing --cov-config=.coveragerc -m "not integration"
pythonpath = .
+markers =
+ integration: end-to-end test that requires a running TTS server and downloads a Whisper model. Excluded by default; opt in with `pytest -m integration`.
diff --git a/ui/lib/api.py b/ui/lib/api.py
index 8bb8b87c..ae647417 100644
--- a/ui/lib/api.py
+++ b/ui/lib/api.py
@@ -16,7 +16,8 @@ def check_api_status() -> Tuple[bool, List[str]]:
timeout=30, # Increased timeout for initial startup period
)
response.raise_for_status()
- voices = response.json().get("voices", [])
+ raw_voices = response.json().get("voices", [])
+ voices = [v["id"] if isinstance(v, dict) else v for v in raw_voices]
if voices:
return True, voices
print("No voices found in response")
diff --git a/web/index.html b/web/index.html
index 3f9db97c..a7a39906 100644
--- a/web/index.html
+++ b/web/index.html
@@ -129,10 +129,17 @@ FastKoko
Cancel
+
+
+