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 @@ Kokoro TTS Banner

-# _`FastKoko`_ -[![Tests](https://img.shields.io/badge/tests-69-darkgreen)]() -[![Coverage](https://img.shields.io/badge/coverage-54%25-tan)]() -[![Try on Spaces](https://img.shields.io/badge/%F0%9F%A4%97%20Try%20on-Spaces-blue)](https://huggingface.co/spaces/Remsky/Kokoro-TTS-Zero) +# _`FastKoko`_ +[![Changelog](https://img.shields.io/badge/changelog-white)](./CHANGELOG.md) [![Tests](https://img.shields.io/badge/tests-100-darkgreen)]() +[![Coverage](https://img.shields.io/badge/coverage-58%25-tan)]() + +[![Kokoro](https://img.shields.io/badge/kokoro-0.9.4-BB5420)](https://github.com/hexgrad/kokoro) +[![Misaki](https://img.shields.io/badge/misaki-0.9.4-B8860B)](https://github.com/hexgrad/misaki) +[![Tested at Model Commit](https://img.shields.io/badge/model-1.0::9901c2b-blue)](https://huggingface.co/hexgrad/Kokoro-82M/commit/9901c2b79161b6e898b7ea857ae5298f47b8b0d6) + +[![Try on Spaces](https://img.shields.io/badge/%F0%9F%A4%97%20Try%20on-Spaces-blue)](https://huggingface.co/spaces/Remsky/FastKoko) [![Downloads](https://img.shields.io/badge/downloads-1.8M%2B-2496ED?logo=docker&logoColor=white)](https://github.com/remsky?tab=packages&repo_name=Kokoro-FastAPI) -[![Kokoro](https://img.shields.io/badge/kokoro-0.9.2-BB5420)](https://github.com/hexgrad/kokoro) -[![Misaki](https://img.shields.io/badge/misaki-0.9.3-B8860B)](https://github.com/hexgrad/misaki) -[![Tested at Model Commit](https://img.shields.io/badge/last--tested--model--commit-1.0::9901c2b-blue)](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 +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/remsky/Kokoro-FastAPI) [![Ask CodeWiki](https://img.shields.io/badge/Ask%20CodeWiki-4285F4?logo=googlegemini&logoColor=white)](https://codewiki.google/github.com/remsky/kokoro-fastapi) + [![Helm Chart](https://img.shields.io/badge/Helm%20Chart-black?style=flat&logo=helm&logoColor=white)](https://github.com/remsky/Kokoro-FastAPI/wiki/Setup-Kubernetes) [![DigitalOcean](https://img.shields.io/badge/DigitalOcean-black?style=flat&logo=digitalocean&logoColor=white)](https://github.com/remsky/Kokoro-FastAPI/wiki/Integrations-DigitalOcean) [![SillyTavern](https://img.shields.io/badge/SillyTavern-black?style=flat&color=red)](https://github.com/remsky/Kokoro-FastAPI/wiki/Integrations-SillyTavern) [![OpenWebUI](https://img.shields.io/badge/OpenWebUI-black?style=flat&color=white)](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* +

+ Short workload + Long-form workload +

+ +| 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. - - - Buy Me A Coffee - - -
@@ -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. + + + Buy Me A Coffee + + + +
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 +
+ + diff --git a/web/src/App.js b/web/src/App.js index 7ab3d7ff..97488423 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -5,6 +5,7 @@ import PlayerControls from './components/PlayerControls.js'; import VoiceSelector from './components/VoiceSelector.js'; import WaveVisualizer from './components/WaveVisualizer.js'; import TextEditor from './components/TextEditor.js'; +import config from './config.js'; export class App { constructor() { @@ -16,7 +17,8 @@ export class App { autoplayToggle: document.getElementById('autoplay-toggle'), formatSelect: document.getElementById('format-select'), status: document.getElementById('status'), - cancelBtn: document.getElementById('cancel-btn') + cancelBtn: document.getElementById('cancel-btn'), + streamingNotice: document.getElementById('streaming-notice') }; this.initialize(); @@ -28,6 +30,8 @@ export class App { this.audioService = new AudioService(); this.voiceService = new VoiceService(); + this.renderVersionBadge(); + // Initialize components this.playerControls = new PlayerControls(this.audioService, this.playerState); this.voiceSelector = new VoiceSelector(this.voiceService); @@ -53,6 +57,47 @@ export class App { this.setupEventListeners(); this.setupAudioEvents(); + this.applyBrowserStreamingNotice(); + } + + async renderVersionBadge() { + const badge = document.getElementById('version-badge'); + if (!badge) return; + try { + await config.ensureInitialized(); + if (config.version) { + badge.textContent = `v${config.version}`; + badge.hidden = false; + } + } catch (_) { + // leave hidden on failure + } + } + + applyBrowserStreamingNotice() { + const notice = this.elements.streamingNotice; + if (!notice) { + return; + } + const format = this.elements.formatSelect?.value || 'mp3'; + const formatLabel = format.toUpperCase(); + const isFirefox = /Firefox\//.test(navigator.userAgent); + let message = ''; + + if (format === 'pcm') { + message = 'PCM output can be generated, but in-browser playback may be unsupported.'; + } else if (format !== 'mp3') { + message = `${formatLabel} output will be generated, playback and/or download will be available when generation finishes.`; + } else if (!this.audioService.supportsMSEMp3()) { + message = isFirefox + ? 'Audio streaming is not currently supported in Firefox. Playback and/or download should stilll be available when generation finishes.' + : 'This browser may not support streaming. Playback and/or download should still be available when generation finishes.'; + } else if (this.elements.autoplayToggle?.checked) { + message = 'Auto-play on: pause after generation completes to enable full seek/scrub.'; + } + + notice.textContent = message; + notice.hidden = !message; } setupEventListeners() { @@ -62,6 +107,10 @@ export class App { // Download button this.elements.downloadBtn.addEventListener('click', () => this.downloadAudio()); + // Keep browser/output warning aligned with the selected format and autoplay state + this.elements.formatSelect.addEventListener('change', () => this.applyBrowserStreamingNotice()); + this.elements.autoplayToggle.addEventListener('change', () => this.applyBrowserStreamingNotice()); + // Cancel button this.elements.cancelBtn.addEventListener('click', () => { this.audioService.cancel(); @@ -110,7 +159,9 @@ export class App { // Handle download ready this.audioService.addEventListener('downloadReady', () => { setTimeout(() => { - this.showStatus('Generation complete', 'success'); + if (!this._playbackFailed) { + this.showStatus('Generation complete', 'success'); + } }, 500); // Small delay to ensure "Preparing file..." is visible }); @@ -125,6 +176,15 @@ export class App { this.setGenerating(false); this.elements.downloadBtn.style.display = 'none'; }); + + // Block-mode playback failure: file is still available for download + this.audioService.addEventListener('playbackUnavailable', () => { + this._playbackFailed = true; + this.showStatus( + 'Playback unavailable in this browser. Use the download below.', + 'info' + ); + }); } showStatus(message, type = 'info') { @@ -169,7 +229,11 @@ export class App { const voice = this.voiceService.getSelectedVoiceString(); const speed = this.playerState.getState().speed; + this.playerState.setReady(false); + this.playerState.setPlaying(false); + this.playerState.setTime(0, 0); this.setGenerating(true); + this._playbackFailed = false; this.elements.downloadBtn.classList.remove('ready'); // Just reset progress bar, don't do full cleanup diff --git a/web/src/components/PlayerControls.js b/web/src/components/PlayerControls.js index 3daf33c8..0bacba2c 100644 --- a/web/src/components/PlayerControls.js +++ b/web/src/components/PlayerControls.js @@ -16,9 +16,14 @@ export class PlayerControls { this.setupAudioEvents(); this.setupStateSubscription(); this.timeUpdateInterval = null; + this.updateControls(this.playerState.getState()); } formatTime(secs) { + if (!Number.isFinite(secs) || secs < 0) { + return '0:00'; + } + const minutes = Math.floor(secs / 60); const seconds = Math.floor(secs % 60); return `${minutes}:${seconds.toString().padStart(2, '0')}`; @@ -47,7 +52,7 @@ export class PlayerControls { `${this.formatTime(currentTime)} / ${this.formatTime(duration || 0)}`; // Update seek slider - if (duration > 0 && !this.elements.seekSlider.dragging) { + if (Number.isFinite(duration) && duration > 0 && !this.elements.seekSlider.dragging) { this.elements.seekSlider.value = (currentTime / duration) * 100; } @@ -123,6 +128,11 @@ export class PlayerControls { this.stopTimeUpdate(); }); + this.audioService.addEventListener('ready', () => { + this.playerState.setReady(true); + this.updateTimeDisplay(); + }); + // Initial time display this.updateTimeDisplay(); } @@ -133,8 +143,8 @@ export class PlayerControls { updateControls(state) { // Update button states - this.elements.playPauseBtn.disabled = !state.duration && !state.isGenerating; - this.elements.seekSlider.disabled = !state.duration; + this.elements.playPauseBtn.disabled = !state.isReady && !state.isGenerating; + this.elements.seekSlider.disabled = !state.isReady || !Number.isFinite(state.duration) || state.duration <= 0; this.elements.cancelBtn.style.display = state.isGenerating ? 'block' : 'none'; // Update volume and speed if changed externally @@ -165,4 +175,4 @@ export class PlayerControls { } } -export default PlayerControls; \ No newline at end of file +export default PlayerControls; diff --git a/web/src/components/WaveVisualizer.js b/web/src/components/WaveVisualizer.js index 15f756fc..d01f25a5 100644 --- a/web/src/components/WaveVisualizer.js +++ b/web/src/components/WaveVisualizer.js @@ -18,7 +18,7 @@ export class WaveVisualizer { height: 100, // Increased height autostart: false, amplitude: 1, - speed: 0.1 + speed: 0.03 }); // Handle window resize @@ -40,6 +40,7 @@ export class WaveVisualizer { } setupStateSubscription() { + this.wasPlaying = false; this.playerState.subscribe(state => { // Handle generation progress if (state.isGenerating) { @@ -53,12 +54,14 @@ export class WaveVisualizer { }, 500); } - // Only animate when playing, stop otherwise - if (state.isPlaying) { + // SiriWave.start() is not idempotent — each call spawns a new RAF + // loop. Only call start/stop on isPlaying transitions. + if (state.isPlaying && !this.wasPlaying) { this.wave.start(); - } else { + } else if (!state.isPlaying && this.wasPlaying) { this.wave.stop(); } + this.wasPlaying = state.isPlaying; }); } @@ -78,8 +81,8 @@ export class WaveVisualizer { cleanup() { if (this.wave) { - this.wave.stop(); - this.wave.dispose(); + if (typeof this.wave.stop === 'function') this.wave.stop(); + if (typeof this.wave.dispose === 'function') this.wave.dispose(); this.wave = null; } diff --git a/web/src/config.js b/web/src/config.js index 00357fde..aad87403 100644 --- a/web/src/config.js +++ b/web/src/config.js @@ -6,6 +6,7 @@ class Config { constructor() { this.rootPath = ''; + this.version = ''; this.initialized = false; this.initPromise = this.initialize(); } @@ -26,6 +27,9 @@ class Config { this.rootPath = serverConfig.root_path.replace(/\/$/, ''); console.log('Config loaded from server. Root path:', this.rootPath); } + if (serverConfig.version !== undefined) { + this.version = serverConfig.version; + } } else { console.log('Using detected root path:', this.rootPath); } diff --git a/web/src/services/AudioService.js b/web/src/services/AudioService.js index 64a6c4ac..7cec31fa 100644 --- a/web/src/services/AudioService.js +++ b/web/src/services/AudioService.js @@ -7,34 +7,68 @@ export class AudioService { this.audio = null; this.controller = null; this.eventListeners = new Map(); - this.minimumPlaybackSize = 50000; // 50KB minimum before playback + this.minimumPlaybackSize = 50000; this.textLength = 0; this.shouldAutoplay = false; - this.CHARS_PER_CHUNK = 150; // Estimated chars per chunk - this.serverDownloadPath = null; // Server-side download path - this.pendingOperations = []; // Queue for buffer operations + this.CHARS_PER_CHUNK = 150; + this.MAX_LEAD_SECONDS = 60; + this.serverDownloadPath = null; + this.pendingOperations = []; + this.objectUrl = null; + this.chunkQueue = []; + this.streamFinished = false; + this.feederWakeup = null; + // once an MSE generation finishes, the full file lives on the server. swapping + // the