diff --git a/.env.example b/.env.example deleted file mode 100644 index e7a6c11..0000000 --- a/.env.example +++ /dev/null @@ -1,44 +0,0 @@ -# ── Server ── -APP_ENV=development # development | production -LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR -HOST=0.0.0.0 # bind address -PORT=8000 # listen port (1–65535) -API_V1_PREFIX=/v1 # URL prefix for versioned routes -WORKERS=1 # uvicorn worker count; RAM = model_size × workers - -# ── Auth (off by default for PoC) ── -API_KEY_ENABLED=false # set true to require X-API-Key header on all routes -API_KEY= # required when API_KEY_ENABLED=true - -# ── Concurrency ── -MAX_CONCURRENT_SYNTHESES=2 # semaphore bound; limits parallel CPU-bound inferences - -# ── Cache (in-memory only for v1; no S3/GCS) ── -CACHE_ENABLED=true # set false to disable caching entirely -CACHE_MAX_ITEMS=256 # max LRU entries; each entry is one audio response - -# ── Providers (comma-separated list) ── -ENABLED_PROVIDERS=pocket # pocket | kokoro | elevenlabs | azure -DEFAULT_PROVIDER=pocket # must be one of ENABLED_PROVIDERS - -# ── PocketTTS ── -POCKET_WEIGHTS_DIR=/app/assets/weights # directory containing baked model weights -POCKET_DEFAULT_VOICE=alba # fallback voice when none specified - -# ── Kokoro (later) ── -KOKORO_MODEL_PATH=/app/assets/weights/kokoro-v1.0.onnx -KOKORO_VOICES_PATH=/app/assets/weights/voices-v1.0.bin - -# ── Commercial proxy (later) ── -ELEVENLABS_API_KEY= # ElevenLabs secret key -ELEVENLABS_BASE_URL=https://api.elevenlabs.io # override for testing/staging -ELEVENLABS_PROXY_URL= # optional outbound proxy (per-provider IP pinning) -AZURE_TTS_KEY= # Azure Cognitive Services speech key -AZURE_TTS_REGION= # Azure region, e.g. eastus -AZURE_PROXY_URL= # optional outbound proxy for Azure - -# ── Audio / ffmpeg ── -FFMPEG_BIN=ffmpeg # path to ffmpeg binary -DEFAULT_AUDIO_FORMAT=mp3 # mp3 | wav | opus -DEFAULT_SAMPLE_RATE=24000 # Hz; native PocketTTS output rate -HTTP_TIMEOUT_SECONDS=30 # timeout for outbound HTTP calls (commercial providers) diff --git a/Dockerfile b/Dockerfile index dd561f3..e0af777 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,37 +9,42 @@ ENV UV_COMPILE_BYTECODE=1 \ UV_LINK_MODE=copy COPY pyproject.toml uv.lock ./ -RUN uv sync --frozen --no-dev --no-install-project +RUN uv sync --frozen --no-install-project COPY app/ ./app/ +COPY tests/ ./tests/ # ── Stage 2: runtime ────────────────────────────────────────────────────────── -# Same base as builder so .venv Python symlinks resolve correctly. FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS runtime RUN apt-get update \ && apt-get install -y --no-install-recommends ffmpeg \ && rm -rf /var/lib/apt/lists/* -RUN useradd --system --create-home --uid 1001 appuser +RUN useradd --system --create-home --uid 1001 appuser \ + && mkdir -p /weights \ + && chown -R appuser:appuser /weights /home/appuser WORKDIR /app -COPY --from=builder /app/.venv /app/.venv -COPY --from=builder /app/app /app/app -COPY scripts/ /app/scripts/ +COPY --from=builder /app/.venv /app/.venv +COPY --from=builder /app/app /app/app +COPY --from=builder /app/pyproject.toml /app/pyproject.toml +COPY scripts/entrypoint.sh /app/scripts/entrypoint.sh RUN chmod +x /app/scripts/entrypoint.sh \ - && mkdir -p /app/assets/weights /app/assets/voices \ && chown -R appuser:appuser /app -ENV PATH="/app/.venv/bin:$PATH" +# HF_HOME=/weights — pocket-tts downloads models here (persisted via named volume) +ENV PATH="/app/.venv/bin:$PATH" \ + HF_HOME=/weights USER appuser EXPOSE 8000 -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ +# start_period covers first-run model downloads (~240 MB per language) +HEALTHCHECK --interval=30s --timeout=5s --start-period=300s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')" ENTRYPOINT ["/app/scripts/entrypoint.sh"] diff --git a/Makefile b/Makefile index 4fdec24..a82744d 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,65 @@ -.PHONY: dev sync test test-integration lint fmt typecheck ci build run stop clean +DC = docker compose -f docker-compose.yml -f docker-compose.dev.yml +DC_RUN = $(DC) run --rm --no-deps app -dev: - uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +.PHONY: build dev-docker dev-docker-build test-docker lint-docker fmt-docker typecheck-docker ci-docker \ + logs start run stop clean configure \ + sync test lint fmt typecheck ci +# ── Docker (primary workflow) ───────────────────────────────────────────────── +build: + docker compose build + +dev-docker: + $(DC) up + +dev-docker-build: + $(DC) up --build + +test-docker: + $(DC_RUN) pytest -m 'not integration and not slow' -v + +test-integration-docker: + $(DC_RUN) pytest -m integration -v + +lint-docker: + $(DC_RUN) ruff check app/ tests/ + +fmt-docker: + $(DC_RUN) ruff format app/ tests/ + +fmt-check-docker: + $(DC_RUN) ruff format --check app/ tests/ + +typecheck-docker: + $(DC_RUN) mypy app/ + +ci-docker: lint-docker fmt-check-docker typecheck-docker test-docker + +logs: + docker compose logs -f app + +start: + docker compose up -d + +run: start + +stop: + docker compose down + +configure: + bash scripts/configure.sh + +clean: + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + find . -name '*.pyc' -delete 2>/dev/null || true + +# ── Local fallback (requires uv + dev deps installed locally) ───────────────── sync: uv sync --dev test: uv run pytest -m 'not integration and not slow' -v -test-integration: - uv run pytest -m 'integration' -v - lint: uv run ruff check app/ tests/ @@ -22,17 +70,3 @@ typecheck: uv run mypy app/ ci: lint fmt typecheck test - -build: - docker build -t speech-server . - -run: - docker compose up - -stop: - docker compose down 2>/dev/null || true - -pkill -f "uvicorn app.main:app" 2>/dev/null || true - -clean: - find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true - find . -name '*.pyc' -delete 2>/dev/null || true diff --git a/README.md b/README.md index 012709f..194bfe2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,334 @@ -# speech-server -An open-source server for hosting and proxying TTS models +# Readium Speech Server + +A remote text-to-speech HTTP service for the [Readium](https://readium.org) ecosystem. Exposes a uniform API for listing voices and synthesizing speech, backed by open neural TTS models running on CPU — no GPU required. + +Designed to pair with [Readium Speech](https://github.com/readium/speech) and any Readium-compatible reading application. + +--- + +## Overview + +| | | +|---|---| +| **API** | `GET /v1/voices` · `POST /v1/synthesize` | +| **Providers** | PocketTTS (v1) · Kokoro, ElevenLabs, Azure (planned) | +| **Languages** | English · French · Italian · German · Spanish · Portuguese | +| **Formats** | MP3 · WAV · Opus | +| **Word boundaries** | Schema ready; supported when provider supplies timing data | +| **Deployment** | Docker · CPU-only · single named volume for model weights | + +--- + +## Quick start + +You need **Docker** — nothing else. No Python, no PyTorch, no model files on your machine. + +```bash +make configure # interactive setup → writes .env +make build # build the image (~2 min) +make dev-docker # start server — downloads models on first run +``` + +Server: `http://localhost:8000` +Interactive API docs: `http://localhost:8000/docs` + +**Quick test — synthesize and play:** +```bash +curl -s -X POST http://localhost:8000/v1/synthesize \ + -H 'Content-Type: application/json' \ + -d '{"text":"Hello world","voice":"urn:readium:tts:pocket:en-alba"}' \ + -o /tmp/speech.mp3 && open /tmp/speech.mp3 +``` + +> **First start** downloads the selected language models (~240 MB each) into a persistent Docker volume. Every restart after that is instant — models are already cached. + +### Production + +```bash +make start # detached, restarts automatically on crash or reboot +make stop # stop all containers +make logs # tail container logs +``` + +--- + +## Setup wizard + +`make configure` opens an interactive wizard. It handles both first-time setup and ongoing management: + +``` +Readium Speech Server + + Current: languages=en workers=1 + + 1) Show full config + 2) Add a language + 3) Remove a language + 4) Change workers + 5) Update HF token + 6) First-time setup (re-run / overwrite) + 7) Reset + q) Quit +``` + +Adding a language updates `.env` in place — only the new model is downloaded on next restart. Removing a language preserves the model files in the Docker volume; disk is reclaimed only if you choose to purge the volume. + +--- + +## Voices + +156 voices across 7 language variants (26 voice identities × 6 languages). Every voice is available in every language — `alba` speaking English, `alba` speaking French, `alba` speaking German, etc. Only languages listed in `LANGUAGES` are loaded at startup (~240 MB RAM per language per worker). + +The 26 voice identities, sourced from [kyutai/tts-voices](https://huggingface.co/kyutai/tts-voices): + +| Voice | Gender | Origin | +|---|---|---| +| alba | female | Alba MacKenna (CC BY 4.0) | +| anna, vera, fantine, charles, paul, eponine, azelma, george, mary, jane, michael, eve | mixed | VCTK dataset (CC BY 4.0) | +| bill_boerst, peter_yearsley, stuart_bell, caro_davy | mixed | Voice Zero / LibriVox (CC0) | +| marius, javert | male | Voice donations (CC0) | +| cosette | female | Expresso dataset (CC BY-NC 4.0) | +| jean | male | EARS dataset (CC BY-NC 4.0) | +| estelle | female | Unmute production voices | +| giovanni, lola, juergen, rafael | mixed | Kyutai (language reference voices) | + +Voice URIs are language-scoped — the same speaker in different languages gets a distinct URI: + +``` +urn:readium:tts:pocket:en-alba # Alba speaking English +urn:readium:tts:pocket:fr-alba # Alba speaking French +urn:readium:tts:pocket:de-alba # Alba speaking German +``` + +Supported language codes: `en`, `fr`, `it`, `de`, `es`, `pt` — derived directly from PocketTTS model names. + +> **How it works:** PocketTTS pre-computes voice embeddings for every voice × language combination (stored as `.safetensors` files in `kyutai/pocket-tts-without-voice-cloning`). The voice sample is encoded once at model-load time — no per-request cloning overhead. + +--- + +## API reference + +### Health + +| Method | Path | Description | +|---|---|---| +| `GET` | `/healthz` | Liveness — 200 when the process is running | +| `GET` | `/readyz` | Readiness — 503 until models are loaded and ffmpeg is available | + +### Voices + +``` +GET /v1/voices +GET /v1/voices?language=fr +GET /v1/voices?provider=pocket +GET /v1/voices?offset=0&limit=20 +``` + +Returns an array of voice objects. Null-valued optional fields are omitted. Each voice includes a `boundary` field indicating whether that provider supports word-level timing marks. + +**Pagination query params:** + +| Param | Type | Description | +|---|---|---| +| `language` | string | Filter by BCP-47 language prefix (e.g. `en`, `fr`) | +| `provider` | string | Filter by provider id (e.g. `pocket`) | +| `offset` | int ≥ 0 | Voices to skip (default: 0) | +| `limit` | int ≥ 1 | Max voices to return (default: all) | + +**Response headers:** + +| Header | Description | +|---|---| +| `X-Total-Count` | Total matching voices before pagination | +| `X-Offset` | Applied offset | +| `X-Limit` | Applied limit (omitted when no limit set) | + +### Synthesize + +``` +POST /v1/synthesize +Content-Type: application/json +``` + +**Minimal request — returns binary MP3:** + +```json +{ + "text": "Hello, world!", + "voice": "urn:readium:tts:pocket:en-alba" +} +``` + +**Full request:** + +```json +{ + "id": "urn:uuid:019f178c-cc7c-7bb3-a39b-d185f43d3cc4", + "text": "Ceci est un test.", + "language": "fr", + "voice": "urn:readium:tts:pocket:fr-estelle", + "ssml": false, + "prev_utterance": "La nuit était sombre.", + "next_utterance": "La pièce était froide.", + "publication_id": "urn:isbn:9780000000000", + "boundary": false, + "output": { + "format": "mp3", + "bitrate": 64, + "speed": 1.0, + "pitch": null + } +} +``` + +**Response (default, `boundary: false`):** + +Binary audio with `Content-Type: audio/mpeg` (or `audio/wav`, `audio/ogg`). + +**Response (`boundary: true`):** + +```json +{ + "audio": "", + "format": "mp3", + "boundaries": [ + { "name": "word", "charIndex": 0, "charLength": 5, "elapsedTime": 0.0 }, + { "name": "word", "charIndex": 6, "charLength": 3, "elapsedTime": 0.38 } + ] +} +``` + +`boundaries` is `null` when the provider does not support word timing. Check `voice.boundary` before requesting — if `false`, the response will always return `null`. + +Word boundary fields mirror the [Web Speech API `boundary` event](https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesisUtterance/boundary_event): `charIndex` and `charLength` index into the original `text`; `elapsedTime` is seconds from audio start. + +**Output formats:** + +| `format` | `Content-Type` | Notes | +|---|---|---| +| `mp3` | `audio/mpeg` | Default | +| `wav` | `audio/wav` | No transcoding — fastest | +| `opus` | `audio/ogg` | Smallest file size | + +**Errors:** + +All errors return a consistent shape: + +```json +{ "error": { "code": "voice_not_found", "message": "...", "detail": null } } +``` + +| Status | Code | Cause | +|---|---|---| +| 400 | `validation_failed` | Empty or whitespace text | +| 404 | `voice_not_found` | Voice URI not registered | +| 413 | `payload_too_large` | Text exceeds `MAX_TEXT_LENGTH` (default 2000 chars) | +| 415 | `unsupported_format` | `format` value not in `mp3`, `wav`, `opus` | +| 422 | — | Request schema invalid (Pydantic detail) | +| 503 | — | Models not yet loaded | + +--- + +## Configuration + +Run `make configure` to generate `.env`, or run `bash scripts/configure.sh` directly. + +| Variable | Default | Description | +|---|---|---| +| `LANGUAGES` | `en` | Comma-separated BCP-47 language codes to load. Supported: `en fr it de es pt` | +| `HF_TOKEN` | _(empty)_ | HuggingFace token. Optional — prevents rate-limiting on first-run model downloads | +| `WORKERS` | `1` | Uvicorn worker processes. Each loads a full copy of every active language model | +| `MAX_CONCURRENT_SYNTHESES` | `2` | Max parallel CPU inference jobs per worker | +| `API_KEY_ENABLED` | `false` | Require `X-API-Key` header on all routes | +| `API_KEY` | _(empty)_ | Key value when `API_KEY_ENABLED=true` | +| `LOG_LEVEL` | `INFO` | `DEBUG` · `INFO` · `WARNING` · `ERROR` | +| `PORT` | `8000` | Listen port | +| `MAX_TEXT_LENGTH` | `2000` | Maximum characters per synthesis request | +| `FFMPEG_BIN` | `ffmpeg` | Path to ffmpeg binary (bundled in the Docker image) | +| `POCKET_DEFAULT_VOICE` | `alba` | Default voice when none is specified | + +**RAM estimate:** `WORKERS × active languages × ~240 MB` + +Example: 2 workers, English + French = `2 × 2 × 240 MB ≈ 960 MB` + +--- + +## Development + +### Commands + +| Command | Description | +|---|---| +| `make configure` | Run setup wizard | +| `make build` | Build Docker image | +| `make dev-docker` | Start dev server with hot-reload | +| `make dev-docker-build` | Build then start | +| `make start` | Start production stack (detached) | +| `make stop` | Stop containers | +| `make logs` | Tail app logs | +| `make test-docker` | Fast test suite — no models needed | +| `make test-integration-docker` | Integration tests — requires models in volume | +| `make ci-docker` | Lint + format check + typecheck + tests | +| `make lint-docker` | `ruff check` | +| `make fmt-docker` | `ruff format` | +| `make typecheck-docker` | `mypy` | +| `make test` | Fast tests via `uv` (no Docker) | +| `make ci` | Full local CI | +| `make clean` | Remove `__pycache__` and `.pyc` files | + +### Running tests locally + +The fast suite requires no models and no ffmpeg — everything is mocked: + +```bash +uv sync +uv run pytest tests/ -m 'not integration and not slow' -v +``` + +### Adding a provider + +1. Create `app/providers/.py` implementing `TTSProvider` +2. Declare `id`, `supported_languages`, and `supports_boundaries` as class variables +3. Implement `_all_voices()` and `synthesize()` +4. Register in `app/main.py` `_build_registry()` + +No changes to routes, synthesizer, or voice catalog. Language filtering and boundary capability are inherited automatically from the base class. + +--- + +## Architecture + +``` +Client + └─ POST /v1/synthesize + └─ Synthesizer + ├─ validate text length + content + ├─ resolve voiceURI → (provider, voiceURI) (VoiceCatalog) + ├─ provider.synthesize() ← runs in thread pool, bounded by semaphore + │ └─ TTSModel.generate_audio() — CPU inference + └─ encode PCM → mp3/opus (ffmpeg driver) or wrap → wav +``` + +- Routes are `async`; all CPU-bound inference runs off the event loop via `anyio.to_thread.run_sync` +- A semaphore (`MAX_CONCURRENT_SYNTHESES`) prevents model thrashing under concurrent load +- Model throughput scales by adding worker processes (`WORKERS`), not threads +- Model weights live in a named Docker volume — downloaded once, instant on every subsequent start + +--- + +## Provider roadmap + +| Provider | Status | Notes | +|---|---|---| +| PocketTTS | Current| CPU · 6 languages · 156 voices (26 identities × 6 languages) | +| Kokoro | 📆 Comming soon | Referenced, not vendored (IP cleanliness) | +| ElevenLabs | 📆 Comming soon | Proxied · word boundaries supported | +| Azure Speech | 📆 Comming soon | Proxied · word boundaries supported | + +--- + +## Related projects + +- [readium/speech](https://github.com/readium/speech) — TypeScript read-aloud library this server is designed to pair with +- [HadrienGardeur/web-speech-recommended-voices](https://github.com/HadrienGardeur/web-speech-recommended-voices) — voice catalog schema reference (CC0) +- [pocket-tts](https://github.com/pocket-tts/pocket-tts) — the underlying CPU TTS engine diff --git a/app/api/deps.py b/app/api/deps.py index e86ae30..1a4ed81 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -21,6 +21,5 @@ def get_synthesizer(catalog: Annotated[VoiceCatalog, Depends(get_voice_catalog)] return Synthesizer(catalog) -RegistryDep = Annotated[ProviderRegistry, Depends(get_registry)] VoiceCatalogDep = Annotated[VoiceCatalog, Depends(get_voice_catalog)] SynthesizerDep = Annotated[Synthesizer, Depends(get_synthesizer)] diff --git a/app/api/v1/routes/health.py b/app/api/v1/routes/health.py index c85f8f1..1353da4 100644 --- a/app/api/v1/routes/health.py +++ b/app/api/v1/routes/health.py @@ -2,6 +2,9 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel +from app.config.settings import settings +from app.drivers import ffmpeg as ffmpeg_driver + router = APIRouter(tags=["health"]) @@ -30,4 +33,14 @@ async def readyz(request: Request) -> JSONResponse: ready: bool = getattr(request.app.state, "ready", False) if not ready: return JSONResponse(status_code=503, content={"status": "not ready"}) + if not ffmpeg_driver.is_available(settings.ffmpeg_bin): + return JSONResponse(status_code=503, content={"status": "ffmpeg not found"}) + registry = getattr(request.app.state, "registry", None) + if registry: + for provider in registry.all(): + if not await provider.health(): + return JSONResponse( + status_code=503, + content={"status": f"provider '{provider.id}' not ready"}, + ) return JSONResponse(status_code=200, content={"status": "ok"}) diff --git a/app/api/v1/routes/synthesize.py b/app/api/v1/routes/synthesize.py index 1481313..6667669 100644 --- a/app/api/v1/routes/synthesize.py +++ b/app/api/v1/routes/synthesize.py @@ -1,5 +1,7 @@ +import base64 + from fastapi import APIRouter -from fastapi.responses import StreamingResponse +from fastapi.responses import JSONResponse, StreamingResponse from app.api.deps import SynthesizerDep from app.api.errors import ErrorResponse @@ -10,14 +12,24 @@ @router.post( "/synthesize", + response_model=None, summary="Synthesize speech from text or SSML", description=( "Accepts an utterance and returns an audio file. " - "Phase 1: WAV only. mp3/opus available after ffmpeg integration (Phase 3)." + "Set `boundary: true` to receive a JSON response with base64-encoded audio " + "and word-level timing marks. " + "Supported formats: `mp3` (default), `wav`, `opus`." ), responses={ + 200: { + "description": ( + "Binary audio (`boundary: false`, default) " + "or `application/json` with base64 audio + boundaries (`boundary: true`)." + ), + }, 400: {"model": ErrorResponse, "description": "Empty or whitespace text"}, 404: {"model": ErrorResponse, "description": "Voice URI not found"}, + 413: {"model": ErrorResponse, "description": "Text exceeds max length"}, 415: {"model": ErrorResponse, "description": "Unsupported audio format"}, 422: {"model": ErrorResponse, "description": "Request schema validation error"}, }, @@ -26,24 +38,44 @@ "content": { "application/json": { "examples": { - "basic_wav": { - "summary": "Basic WAV synthesis", + "basic": { + "summary": "Basic request (mp3 default)", + "value": { + "id": "urn:uuid:019f1784-d800-7cc5-9f39-39c1e1ca6fdd", + "text": "Hello, this is a test.", + "language": "en", + "voice": "urn:readium:tts:pocket:en-alba", + }, + }, + "opus_32kbps": { + "summary": "OPUS at 32 kbps", "value": { - "text": "Hello, world!", - "voice": "urn:readium:tts:fake:en-US-standard", - "format": "wav", + "id": "urn:uuid:019f1784-d800-7cc5-9f39-39c1e1ca6fdd", + "text": "Hello, this is a test.", + "language": "en", + "voice": "urn:readium:tts:pocket:en-alba", + "output": {"format": "opus", "bitrate": 32}, }, }, - "with_prosody": { - "summary": "With speed and context", + "with_boundaries": { + "summary": "Request word-level timing marks", + "value": { + "id": "urn:uuid:019f178c-cc7c-7bb3-a39b-d185f43d3cc4", + "text": "Ceci est un test.", + "language": "fr", + "voice": "urn:readium:tts:pocket:fr-estelle", + "boundary": True, + }, + }, + "with_context": { + "summary": "With prosody context and speed", "value": { "text": "She opened the door.", - "voice": "urn:readium:tts:fake:en-US-standard", - "format": "wav", - "language": "en-US", - "speed": 0.9, + "language": "en", + "voice": "urn:readium:tts:pocket:en-alba", "prev_utterance": "It was a dark night.", "next_utterance": "The room was cold.", + "output": {"format": "mp3", "speed": 0.9}, }, }, } @@ -55,11 +87,25 @@ async def synthesize( request: SynthesizeRequest, synthesizer: SynthesizerDep, -) -> StreamingResponse: - audio_bytes, content_type = await synthesizer.synthesize(request) - ext = content_type.split("/")[-1].replace("mpeg", "mp3").replace("ogg", "ogg") +) -> StreamingResponse | JSONResponse: + audio_bytes, content_type, boundaries, boundaries_supported = await synthesizer.synthesize( + request + ) + + if request.boundary: + return JSONResponse( + { + "audio": base64.b64encode(audio_bytes).decode(), + "format": request.output.format.value, + "boundaries": [m.model_dump() for m in boundaries] + if boundaries_supported + else None, + } + ) + + ext = content_type.split("/")[-1].replace("mpeg", "mp3") return StreamingResponse( iter([audio_bytes]), media_type=content_type, - headers={"Content-Disposition": f"attachment; filename=speech.{ext}"}, + headers={"Content-Disposition": f"inline; filename=speech.{ext}"}, ) diff --git a/app/api/v1/routes/voices.py b/app/api/v1/routes/voices.py index dff0b68..adc114a 100644 --- a/app/api/v1/routes/voices.py +++ b/app/api/v1/routes/voices.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Query, Response from app.api.deps import VoiceCatalogDep from app.api.errors import ErrorResponse @@ -10,10 +10,13 @@ @router.get( "/voices", response_model=list[Voice], + response_model_exclude_none=True, summary="List available TTS voices", description=( - "Returns all voices registered across enabled providers, " - "optionally filtered by BCP-47 language tag prefix or provider id." + "Returns voices registered across enabled providers, " + "optionally filtered by language or provider. " + "Supports pagination via `offset` and `limit`. " + "Response headers `X-Total-Count`, `X-Offset`, `X-Limit` reflect the full result set size." ), responses={ 502: {"model": ErrorResponse, "description": "Provider unavailable"}, @@ -28,14 +31,21 @@ "summary": "All voices", "value": [ { - "label": "Fake Voice (English)", - "voiceURI": "urn:readium:tts:fake:en-US-standard", - "name": "fake-en-US", - "language": "en-US", - "provider": "fake", - "engineVoiceId": "fake-en", - "sampleRate": 22050, - "mimeTypes": ["audio/wav"], + "source": "json", + "label": "Alba (English)", + "name": "pocket-en-alba", + "originalName": "alba", + "voiceURI": "urn:readium:tts:pocket:en-alba", + "language": "en", + "gender": "female", + "quality": "normal", + "pitchControl": False, + "preloaded": True, + "provider": "pocket", + "engineVoiceId": "alba", + "sampleRate": 24000, + "mimeTypes": ["audio/mpeg", "audio/wav", "audio/ogg"], + "boundary": False, } ], } @@ -47,8 +57,18 @@ }, ) async def list_voices( + response: Response, catalog: VoiceCatalogDep, language: str | None = None, provider: str | None = None, + offset: int = Query(default=0, ge=0, description="Number of voices to skip"), + limit: int | None = Query(default=None, ge=1, description="Max voices to return"), ) -> list[Voice]: - return catalog.list(language=language, provider=provider) + all_voices = catalog.list(language=language, provider=provider) + total = len(all_voices) + page = all_voices[offset : offset + limit] if limit is not None else all_voices[offset:] + response.headers["X-Total-Count"] = str(total) + response.headers["X-Offset"] = str(offset) + if limit is not None: + response.headers["X-Limit"] = str(limit) + return page diff --git a/app/config/settings.py b/app/config/settings.py index 1c4d39d..f3372b0 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -25,23 +25,26 @@ class Settings(BaseSettings): # Concurrency max_concurrent_syntheses: int = Field(default=2, ge=1) - # Cache (in-memory only for v1) - cache_enabled: bool = True - cache_max_items: int = Field(default=256, ge=1) - # Providers enabled_providers: str = "pocket" default_provider: str = "pocket" + # Languages (global; providers filter to their supported subset via active_languages()) + # Stored as comma-separated string to avoid pydantic-settings JSON-parsing list fields from env. + languages: str = "en" + hf_token: str = "" + + @property + def language_list(self) -> list[str]: + langs = [v.strip().lower() for v in self.languages.split(",") if v.strip()] + return langs or ["en"] + # PocketTTS - pocket_weights_dir: str = "/app/assets/weights" pocket_default_voice: str = "alba" # Audio / ffmpeg + max_text_length: int = Field(default=2000, ge=1) ffmpeg_bin: str = "ffmpeg" - default_audio_format: str = "mp3" - default_sample_rate: int = Field(default=24000, ge=1) - http_timeout_seconds: int = Field(default=30, ge=1) @model_validator(mode="after") def validate_auth_and_providers(self) -> "Settings": diff --git a/app/core/synthesizer.py b/app/core/synthesizer.py index 74bdc49..53d6cdc 100644 --- a/app/core/synthesizer.py +++ b/app/core/synthesizer.py @@ -1,9 +1,12 @@ +import logging import struct -from app.api.errors import RequestValidationFailed, UnsupportedFormat +from app.api.errors import PayloadTooLarge, RequestValidationFailed +from app.config.settings import settings from app.core.voice_catalog import VoiceCatalog from app.domain.enums import AudioFormat -from app.schemas.audio import AudioResult, SynthesisParams +from app.drivers import ffmpeg as ffmpeg_driver +from app.schemas.audio import AudioResult, SynthesisParams, TimingMark from app.schemas.utterance import SynthesizeRequest _WAV_BITS = 16 @@ -31,39 +34,72 @@ def _pcm_to_wav(pcm: bytes, sample_rate: int) -> bytes: return header + pcm +logger = logging.getLogger(__name__) + + class Synthesizer: def __init__(self, catalog: VoiceCatalog) -> None: self._catalog = catalog - async def synthesize(self, request: SynthesizeRequest) -> tuple[bytes, str]: - if not request.text.strip(): + async def synthesize( + self, request: SynthesizeRequest + ) -> tuple[bytes, str, list[TimingMark], bool]: + text = request.text.strip() + if not text: raise RequestValidationFailed("text must not be empty or whitespace") - # Phase 1: WAV only; ffmpeg formats land in Phase 3 - if request.format != AudioFormat.WAV: - raise UnsupportedFormat( - f"Format '{request.format}' not supported yet — use 'wav'", - detail="mp3 and opus will be available after Phase 3 (ffmpeg integration)", + if len(text) > settings.max_text_length: + raise PayloadTooLarge( + f"text exceeds {settings.max_text_length} characters", + detail=f"received {len(text)}, limit is {settings.max_text_length}", ) - provider, engine_voice_id = self._catalog.resolve(request.voice) + provider, voice_uri = self._catalog.resolve(request.voice) + boundaries_supported = provider.supports_boundaries + out = request.output + logger.info( + "synthesize voice=%s provider=%s format=%s chars=%d boundary=%s", + voice_uri, + provider.id, + out.format.value, + len(text), + request.boundary, + ) params = SynthesisParams( - text=request.text, + text=text, ssml=request.ssml, language=request.language, - engineVoiceId=engine_voice_id, - speed=request.speed, - pitch=request.pitch, + voice_uri=voice_uri, + speed=out.speed, + pitch=out.pitch, prev_utterance=request.prev_utterance, next_utterance=request.next_utterance, ) result: AudioResult = await provider.synthesize(params) - if result.encoded is not None: - content_type = f"audio/{result.format or 'wav'}" - return result.encoded, content_type - if result.pcm is None: - raise ValueError("AudioResult must set either pcm or encoded") - wav_bytes = _pcm_to_wav(result.pcm, result.sample_rate) - return wav_bytes, "audio/wav" + + if out.format == AudioFormat.WAV: + audio = _pcm_to_wav(result.pcm, result.sample_rate) + logger.info("generated wav pcm_bytes=%d output_bytes=%d", len(result.pcm), len(audio)) + return (audio, "audio/wav", result.boundaries, boundaries_supported) + + encoded = await ffmpeg_driver.encode( + result.pcm, + result.sample_rate, + out.format.value, + ffmpeg_bin=settings.ffmpeg_bin, + bitrate=out.bitrate, + ) + logger.info( + "generated %s pcm_bytes=%d output_bytes=%d", + out.format.value, + len(result.pcm), + len(encoded), + ) + return ( + encoded, + ffmpeg_driver.content_type(out.format.value), + result.boundaries, + boundaries_supported, + ) diff --git a/app/core/voice_catalog.py b/app/core/voice_catalog.py index 23bf647..66b1f1b 100644 --- a/app/core/voice_catalog.py +++ b/app/core/voice_catalog.py @@ -16,14 +16,15 @@ async def load(self) -> None: for provider in self._registry.all(): for voice in await provider.list_voices(): voices.append(voice) - index[voice.voiceURI] = (provider, voice.engineVoiceId) + index[voice.voiceURI] = (provider, voice.voiceURI) self._voices = voices self._index = index def list(self, language: str | None = None, provider: str | None = None) -> list[Voice]: result = self._voices if language: - result = [v for v in result if v.language.lower().startswith(language.lower())] + lang_prefix = language.split("-")[0].lower() + result = [v for v in result if v.language.split("-")[0].lower() == lang_prefix] if provider: result = [v for v in result if v.provider == provider] return result diff --git a/app/data/voices/pocket/voices.json b/app/data/voices/pocket/voices.json new file mode 100644 index 0000000..7e57798 --- /dev/null +++ b/app/data/voices/pocket/voices.json @@ -0,0 +1,3122 @@ +[ + { + "source": "json", + "label": "Alba (English)", + "name": "pocket-en-alba", + "originalName": "alba", + "voiceURI": "urn:readium:tts:pocket:en-alba", + "language": "en", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "alba", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Anna (English)", + "name": "pocket-en-anna", + "originalName": "anna", + "voiceURI": "urn:readium:tts:pocket:en-anna", + "language": "en", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "anna", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Azelma (English)", + "name": "pocket-en-azelma", + "originalName": "azelma", + "voiceURI": "urn:readium:tts:pocket:en-azelma", + "language": "en", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "azelma", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Bill Boerst (English)", + "name": "pocket-en-bill_boerst", + "originalName": "bill_boerst", + "voiceURI": "urn:readium:tts:pocket:en-bill_boerst", + "language": "en", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "bill_boerst", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Caro Davy (English)", + "name": "pocket-en-caro_davy", + "originalName": "caro_davy", + "voiceURI": "urn:readium:tts:pocket:en-caro_davy", + "language": "en", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "caro_davy", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Charles (English)", + "name": "pocket-en-charles", + "originalName": "charles", + "voiceURI": "urn:readium:tts:pocket:en-charles", + "language": "en", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "charles", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Cosette (English)", + "name": "pocket-en-cosette", + "originalName": "cosette", + "voiceURI": "urn:readium:tts:pocket:en-cosette", + "language": "en", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "cosette", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Eponine (English)", + "name": "pocket-en-eponine", + "originalName": "eponine", + "voiceURI": "urn:readium:tts:pocket:en-eponine", + "language": "en", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "eponine", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Estelle (English)", + "name": "pocket-en-estelle", + "originalName": "estelle", + "voiceURI": "urn:readium:tts:pocket:en-estelle", + "language": "en", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "estelle", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Eve (English)", + "name": "pocket-en-eve", + "originalName": "eve", + "voiceURI": "urn:readium:tts:pocket:en-eve", + "language": "en", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "eve", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Fantine (English)", + "name": "pocket-en-fantine", + "originalName": "fantine", + "voiceURI": "urn:readium:tts:pocket:en-fantine", + "language": "en", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "fantine", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "George (English)", + "name": "pocket-en-george", + "originalName": "george", + "voiceURI": "urn:readium:tts:pocket:en-george", + "language": "en", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "george", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Giovanni (English)", + "name": "pocket-en-giovanni", + "originalName": "giovanni", + "voiceURI": "urn:readium:tts:pocket:en-giovanni", + "language": "en", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "giovanni", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Jane (English)", + "name": "pocket-en-jane", + "originalName": "jane", + "voiceURI": "urn:readium:tts:pocket:en-jane", + "language": "en", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "jane", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Javert (English)", + "name": "pocket-en-javert", + "originalName": "javert", + "voiceURI": "urn:readium:tts:pocket:en-javert", + "language": "en", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "javert", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Jean (English)", + "name": "pocket-en-jean", + "originalName": "jean", + "voiceURI": "urn:readium:tts:pocket:en-jean", + "language": "en", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "jean", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Juergen (English)", + "name": "pocket-en-juergen", + "originalName": "juergen", + "voiceURI": "urn:readium:tts:pocket:en-juergen", + "language": "en", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "juergen", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Lola (English)", + "name": "pocket-en-lola", + "originalName": "lola", + "voiceURI": "urn:readium:tts:pocket:en-lola", + "language": "en", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "lola", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Marius (English)", + "name": "pocket-en-marius", + "originalName": "marius", + "voiceURI": "urn:readium:tts:pocket:en-marius", + "language": "en", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "marius", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Mary (English)", + "name": "pocket-en-mary", + "originalName": "mary", + "voiceURI": "urn:readium:tts:pocket:en-mary", + "language": "en", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "mary", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Michael (English)", + "name": "pocket-en-michael", + "originalName": "michael", + "voiceURI": "urn:readium:tts:pocket:en-michael", + "language": "en", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "michael", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Paul (English)", + "name": "pocket-en-paul", + "originalName": "paul", + "voiceURI": "urn:readium:tts:pocket:en-paul", + "language": "en", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "paul", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Peter Yearsley (English)", + "name": "pocket-en-peter_yearsley", + "originalName": "peter_yearsley", + "voiceURI": "urn:readium:tts:pocket:en-peter_yearsley", + "language": "en", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "peter_yearsley", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Rafael (English)", + "name": "pocket-en-rafael", + "originalName": "rafael", + "voiceURI": "urn:readium:tts:pocket:en-rafael", + "language": "en", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "rafael", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Stuart Bell (English)", + "name": "pocket-en-stuart_bell", + "originalName": "stuart_bell", + "voiceURI": "urn:readium:tts:pocket:en-stuart_bell", + "language": "en", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "stuart_bell", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Vera (English)", + "name": "pocket-en-vera", + "originalName": "vera", + "voiceURI": "urn:readium:tts:pocket:en-vera", + "language": "en", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "vera", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Alba (French)", + "name": "pocket-fr-alba", + "originalName": "alba", + "voiceURI": "urn:readium:tts:pocket:fr-alba", + "language": "fr", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "alba", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Anna (French)", + "name": "pocket-fr-anna", + "originalName": "anna", + "voiceURI": "urn:readium:tts:pocket:fr-anna", + "language": "fr", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "anna", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Azelma (French)", + "name": "pocket-fr-azelma", + "originalName": "azelma", + "voiceURI": "urn:readium:tts:pocket:fr-azelma", + "language": "fr", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "azelma", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Bill Boerst (French)", + "name": "pocket-fr-bill_boerst", + "originalName": "bill_boerst", + "voiceURI": "urn:readium:tts:pocket:fr-bill_boerst", + "language": "fr", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "bill_boerst", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Caro Davy (French)", + "name": "pocket-fr-caro_davy", + "originalName": "caro_davy", + "voiceURI": "urn:readium:tts:pocket:fr-caro_davy", + "language": "fr", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "caro_davy", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Charles (French)", + "name": "pocket-fr-charles", + "originalName": "charles", + "voiceURI": "urn:readium:tts:pocket:fr-charles", + "language": "fr", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "charles", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Cosette (French)", + "name": "pocket-fr-cosette", + "originalName": "cosette", + "voiceURI": "urn:readium:tts:pocket:fr-cosette", + "language": "fr", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "cosette", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Eponine (French)", + "name": "pocket-fr-eponine", + "originalName": "eponine", + "voiceURI": "urn:readium:tts:pocket:fr-eponine", + "language": "fr", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "eponine", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Estelle (French)", + "name": "pocket-fr-estelle", + "originalName": "estelle", + "voiceURI": "urn:readium:tts:pocket:fr-estelle", + "language": "fr", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "estelle", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Eve (French)", + "name": "pocket-fr-eve", + "originalName": "eve", + "voiceURI": "urn:readium:tts:pocket:fr-eve", + "language": "fr", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "eve", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Fantine (French)", + "name": "pocket-fr-fantine", + "originalName": "fantine", + "voiceURI": "urn:readium:tts:pocket:fr-fantine", + "language": "fr", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "fantine", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "George (French)", + "name": "pocket-fr-george", + "originalName": "george", + "voiceURI": "urn:readium:tts:pocket:fr-george", + "language": "fr", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "george", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Giovanni (French)", + "name": "pocket-fr-giovanni", + "originalName": "giovanni", + "voiceURI": "urn:readium:tts:pocket:fr-giovanni", + "language": "fr", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "giovanni", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Jane (French)", + "name": "pocket-fr-jane", + "originalName": "jane", + "voiceURI": "urn:readium:tts:pocket:fr-jane", + "language": "fr", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "jane", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Javert (French)", + "name": "pocket-fr-javert", + "originalName": "javert", + "voiceURI": "urn:readium:tts:pocket:fr-javert", + "language": "fr", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "javert", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Jean (French)", + "name": "pocket-fr-jean", + "originalName": "jean", + "voiceURI": "urn:readium:tts:pocket:fr-jean", + "language": "fr", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "jean", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Juergen (French)", + "name": "pocket-fr-juergen", + "originalName": "juergen", + "voiceURI": "urn:readium:tts:pocket:fr-juergen", + "language": "fr", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "juergen", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Lola (French)", + "name": "pocket-fr-lola", + "originalName": "lola", + "voiceURI": "urn:readium:tts:pocket:fr-lola", + "language": "fr", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "lola", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Marius (French)", + "name": "pocket-fr-marius", + "originalName": "marius", + "voiceURI": "urn:readium:tts:pocket:fr-marius", + "language": "fr", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "marius", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Mary (French)", + "name": "pocket-fr-mary", + "originalName": "mary", + "voiceURI": "urn:readium:tts:pocket:fr-mary", + "language": "fr", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "mary", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Michael (French)", + "name": "pocket-fr-michael", + "originalName": "michael", + "voiceURI": "urn:readium:tts:pocket:fr-michael", + "language": "fr", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "michael", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Paul (French)", + "name": "pocket-fr-paul", + "originalName": "paul", + "voiceURI": "urn:readium:tts:pocket:fr-paul", + "language": "fr", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "paul", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Peter Yearsley (French)", + "name": "pocket-fr-peter_yearsley", + "originalName": "peter_yearsley", + "voiceURI": "urn:readium:tts:pocket:fr-peter_yearsley", + "language": "fr", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "peter_yearsley", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Rafael (French)", + "name": "pocket-fr-rafael", + "originalName": "rafael", + "voiceURI": "urn:readium:tts:pocket:fr-rafael", + "language": "fr", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "rafael", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Stuart Bell (French)", + "name": "pocket-fr-stuart_bell", + "originalName": "stuart_bell", + "voiceURI": "urn:readium:tts:pocket:fr-stuart_bell", + "language": "fr", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "stuart_bell", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Vera (French)", + "name": "pocket-fr-vera", + "originalName": "vera", + "voiceURI": "urn:readium:tts:pocket:fr-vera", + "language": "fr", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "vera", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Alba (Italian)", + "name": "pocket-it-alba", + "originalName": "alba", + "voiceURI": "urn:readium:tts:pocket:it-alba", + "language": "it", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "alba", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Anna (Italian)", + "name": "pocket-it-anna", + "originalName": "anna", + "voiceURI": "urn:readium:tts:pocket:it-anna", + "language": "it", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "anna", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Azelma (Italian)", + "name": "pocket-it-azelma", + "originalName": "azelma", + "voiceURI": "urn:readium:tts:pocket:it-azelma", + "language": "it", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "azelma", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Bill Boerst (Italian)", + "name": "pocket-it-bill_boerst", + "originalName": "bill_boerst", + "voiceURI": "urn:readium:tts:pocket:it-bill_boerst", + "language": "it", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "bill_boerst", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Caro Davy (Italian)", + "name": "pocket-it-caro_davy", + "originalName": "caro_davy", + "voiceURI": "urn:readium:tts:pocket:it-caro_davy", + "language": "it", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "caro_davy", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Charles (Italian)", + "name": "pocket-it-charles", + "originalName": "charles", + "voiceURI": "urn:readium:tts:pocket:it-charles", + "language": "it", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "charles", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Cosette (Italian)", + "name": "pocket-it-cosette", + "originalName": "cosette", + "voiceURI": "urn:readium:tts:pocket:it-cosette", + "language": "it", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "cosette", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Eponine (Italian)", + "name": "pocket-it-eponine", + "originalName": "eponine", + "voiceURI": "urn:readium:tts:pocket:it-eponine", + "language": "it", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "eponine", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Estelle (Italian)", + "name": "pocket-it-estelle", + "originalName": "estelle", + "voiceURI": "urn:readium:tts:pocket:it-estelle", + "language": "it", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "estelle", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Eve (Italian)", + "name": "pocket-it-eve", + "originalName": "eve", + "voiceURI": "urn:readium:tts:pocket:it-eve", + "language": "it", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "eve", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Fantine (Italian)", + "name": "pocket-it-fantine", + "originalName": "fantine", + "voiceURI": "urn:readium:tts:pocket:it-fantine", + "language": "it", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "fantine", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "George (Italian)", + "name": "pocket-it-george", + "originalName": "george", + "voiceURI": "urn:readium:tts:pocket:it-george", + "language": "it", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "george", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Giovanni (Italian)", + "name": "pocket-it-giovanni", + "originalName": "giovanni", + "voiceURI": "urn:readium:tts:pocket:it-giovanni", + "language": "it", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "giovanni", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Jane (Italian)", + "name": "pocket-it-jane", + "originalName": "jane", + "voiceURI": "urn:readium:tts:pocket:it-jane", + "language": "it", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "jane", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Javert (Italian)", + "name": "pocket-it-javert", + "originalName": "javert", + "voiceURI": "urn:readium:tts:pocket:it-javert", + "language": "it", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "javert", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Jean (Italian)", + "name": "pocket-it-jean", + "originalName": "jean", + "voiceURI": "urn:readium:tts:pocket:it-jean", + "language": "it", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "jean", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Juergen (Italian)", + "name": "pocket-it-juergen", + "originalName": "juergen", + "voiceURI": "urn:readium:tts:pocket:it-juergen", + "language": "it", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "juergen", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Lola (Italian)", + "name": "pocket-it-lola", + "originalName": "lola", + "voiceURI": "urn:readium:tts:pocket:it-lola", + "language": "it", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "lola", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Marius (Italian)", + "name": "pocket-it-marius", + "originalName": "marius", + "voiceURI": "urn:readium:tts:pocket:it-marius", + "language": "it", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "marius", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Mary (Italian)", + "name": "pocket-it-mary", + "originalName": "mary", + "voiceURI": "urn:readium:tts:pocket:it-mary", + "language": "it", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "mary", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Michael (Italian)", + "name": "pocket-it-michael", + "originalName": "michael", + "voiceURI": "urn:readium:tts:pocket:it-michael", + "language": "it", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "michael", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Paul (Italian)", + "name": "pocket-it-paul", + "originalName": "paul", + "voiceURI": "urn:readium:tts:pocket:it-paul", + "language": "it", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "paul", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Peter Yearsley (Italian)", + "name": "pocket-it-peter_yearsley", + "originalName": "peter_yearsley", + "voiceURI": "urn:readium:tts:pocket:it-peter_yearsley", + "language": "it", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "peter_yearsley", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Rafael (Italian)", + "name": "pocket-it-rafael", + "originalName": "rafael", + "voiceURI": "urn:readium:tts:pocket:it-rafael", + "language": "it", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "rafael", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Stuart Bell (Italian)", + "name": "pocket-it-stuart_bell", + "originalName": "stuart_bell", + "voiceURI": "urn:readium:tts:pocket:it-stuart_bell", + "language": "it", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "stuart_bell", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Vera (Italian)", + "name": "pocket-it-vera", + "originalName": "vera", + "voiceURI": "urn:readium:tts:pocket:it-vera", + "language": "it", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "vera", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Alba (German)", + "name": "pocket-de-alba", + "originalName": "alba", + "voiceURI": "urn:readium:tts:pocket:de-alba", + "language": "de", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "alba", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Anna (German)", + "name": "pocket-de-anna", + "originalName": "anna", + "voiceURI": "urn:readium:tts:pocket:de-anna", + "language": "de", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "anna", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Azelma (German)", + "name": "pocket-de-azelma", + "originalName": "azelma", + "voiceURI": "urn:readium:tts:pocket:de-azelma", + "language": "de", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "azelma", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Bill Boerst (German)", + "name": "pocket-de-bill_boerst", + "originalName": "bill_boerst", + "voiceURI": "urn:readium:tts:pocket:de-bill_boerst", + "language": "de", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "bill_boerst", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Caro Davy (German)", + "name": "pocket-de-caro_davy", + "originalName": "caro_davy", + "voiceURI": "urn:readium:tts:pocket:de-caro_davy", + "language": "de", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "caro_davy", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Charles (German)", + "name": "pocket-de-charles", + "originalName": "charles", + "voiceURI": "urn:readium:tts:pocket:de-charles", + "language": "de", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "charles", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Cosette (German)", + "name": "pocket-de-cosette", + "originalName": "cosette", + "voiceURI": "urn:readium:tts:pocket:de-cosette", + "language": "de", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "cosette", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Eponine (German)", + "name": "pocket-de-eponine", + "originalName": "eponine", + "voiceURI": "urn:readium:tts:pocket:de-eponine", + "language": "de", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "eponine", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Estelle (German)", + "name": "pocket-de-estelle", + "originalName": "estelle", + "voiceURI": "urn:readium:tts:pocket:de-estelle", + "language": "de", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "estelle", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Eve (German)", + "name": "pocket-de-eve", + "originalName": "eve", + "voiceURI": "urn:readium:tts:pocket:de-eve", + "language": "de", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "eve", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Fantine (German)", + "name": "pocket-de-fantine", + "originalName": "fantine", + "voiceURI": "urn:readium:tts:pocket:de-fantine", + "language": "de", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "fantine", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "George (German)", + "name": "pocket-de-george", + "originalName": "george", + "voiceURI": "urn:readium:tts:pocket:de-george", + "language": "de", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "george", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Giovanni (German)", + "name": "pocket-de-giovanni", + "originalName": "giovanni", + "voiceURI": "urn:readium:tts:pocket:de-giovanni", + "language": "de", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "giovanni", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Jane (German)", + "name": "pocket-de-jane", + "originalName": "jane", + "voiceURI": "urn:readium:tts:pocket:de-jane", + "language": "de", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "jane", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Javert (German)", + "name": "pocket-de-javert", + "originalName": "javert", + "voiceURI": "urn:readium:tts:pocket:de-javert", + "language": "de", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "javert", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Jean (German)", + "name": "pocket-de-jean", + "originalName": "jean", + "voiceURI": "urn:readium:tts:pocket:de-jean", + "language": "de", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "jean", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Juergen (German)", + "name": "pocket-de-juergen", + "originalName": "juergen", + "voiceURI": "urn:readium:tts:pocket:de-juergen", + "language": "de", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "juergen", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Lola (German)", + "name": "pocket-de-lola", + "originalName": "lola", + "voiceURI": "urn:readium:tts:pocket:de-lola", + "language": "de", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "lola", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Marius (German)", + "name": "pocket-de-marius", + "originalName": "marius", + "voiceURI": "urn:readium:tts:pocket:de-marius", + "language": "de", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "marius", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Mary (German)", + "name": "pocket-de-mary", + "originalName": "mary", + "voiceURI": "urn:readium:tts:pocket:de-mary", + "language": "de", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "mary", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Michael (German)", + "name": "pocket-de-michael", + "originalName": "michael", + "voiceURI": "urn:readium:tts:pocket:de-michael", + "language": "de", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "michael", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Paul (German)", + "name": "pocket-de-paul", + "originalName": "paul", + "voiceURI": "urn:readium:tts:pocket:de-paul", + "language": "de", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "paul", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Peter Yearsley (German)", + "name": "pocket-de-peter_yearsley", + "originalName": "peter_yearsley", + "voiceURI": "urn:readium:tts:pocket:de-peter_yearsley", + "language": "de", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "peter_yearsley", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Rafael (German)", + "name": "pocket-de-rafael", + "originalName": "rafael", + "voiceURI": "urn:readium:tts:pocket:de-rafael", + "language": "de", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "rafael", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Stuart Bell (German)", + "name": "pocket-de-stuart_bell", + "originalName": "stuart_bell", + "voiceURI": "urn:readium:tts:pocket:de-stuart_bell", + "language": "de", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "stuart_bell", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Vera (German)", + "name": "pocket-de-vera", + "originalName": "vera", + "voiceURI": "urn:readium:tts:pocket:de-vera", + "language": "de", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "vera", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Alba (Spanish)", + "name": "pocket-es-alba", + "originalName": "alba", + "voiceURI": "urn:readium:tts:pocket:es-alba", + "language": "es", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "alba", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Anna (Spanish)", + "name": "pocket-es-anna", + "originalName": "anna", + "voiceURI": "urn:readium:tts:pocket:es-anna", + "language": "es", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "anna", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Azelma (Spanish)", + "name": "pocket-es-azelma", + "originalName": "azelma", + "voiceURI": "urn:readium:tts:pocket:es-azelma", + "language": "es", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "azelma", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Bill Boerst (Spanish)", + "name": "pocket-es-bill_boerst", + "originalName": "bill_boerst", + "voiceURI": "urn:readium:tts:pocket:es-bill_boerst", + "language": "es", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "bill_boerst", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Caro Davy (Spanish)", + "name": "pocket-es-caro_davy", + "originalName": "caro_davy", + "voiceURI": "urn:readium:tts:pocket:es-caro_davy", + "language": "es", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "caro_davy", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Charles (Spanish)", + "name": "pocket-es-charles", + "originalName": "charles", + "voiceURI": "urn:readium:tts:pocket:es-charles", + "language": "es", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "charles", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Cosette (Spanish)", + "name": "pocket-es-cosette", + "originalName": "cosette", + "voiceURI": "urn:readium:tts:pocket:es-cosette", + "language": "es", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "cosette", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Eponine (Spanish)", + "name": "pocket-es-eponine", + "originalName": "eponine", + "voiceURI": "urn:readium:tts:pocket:es-eponine", + "language": "es", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "eponine", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Estelle (Spanish)", + "name": "pocket-es-estelle", + "originalName": "estelle", + "voiceURI": "urn:readium:tts:pocket:es-estelle", + "language": "es", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "estelle", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Eve (Spanish)", + "name": "pocket-es-eve", + "originalName": "eve", + "voiceURI": "urn:readium:tts:pocket:es-eve", + "language": "es", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "eve", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Fantine (Spanish)", + "name": "pocket-es-fantine", + "originalName": "fantine", + "voiceURI": "urn:readium:tts:pocket:es-fantine", + "language": "es", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "fantine", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "George (Spanish)", + "name": "pocket-es-george", + "originalName": "george", + "voiceURI": "urn:readium:tts:pocket:es-george", + "language": "es", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "george", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Giovanni (Spanish)", + "name": "pocket-es-giovanni", + "originalName": "giovanni", + "voiceURI": "urn:readium:tts:pocket:es-giovanni", + "language": "es", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "giovanni", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Jane (Spanish)", + "name": "pocket-es-jane", + "originalName": "jane", + "voiceURI": "urn:readium:tts:pocket:es-jane", + "language": "es", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "jane", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Javert (Spanish)", + "name": "pocket-es-javert", + "originalName": "javert", + "voiceURI": "urn:readium:tts:pocket:es-javert", + "language": "es", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "javert", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Jean (Spanish)", + "name": "pocket-es-jean", + "originalName": "jean", + "voiceURI": "urn:readium:tts:pocket:es-jean", + "language": "es", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "jean", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Juergen (Spanish)", + "name": "pocket-es-juergen", + "originalName": "juergen", + "voiceURI": "urn:readium:tts:pocket:es-juergen", + "language": "es", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "juergen", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Lola (Spanish)", + "name": "pocket-es-lola", + "originalName": "lola", + "voiceURI": "urn:readium:tts:pocket:es-lola", + "language": "es", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "lola", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Marius (Spanish)", + "name": "pocket-es-marius", + "originalName": "marius", + "voiceURI": "urn:readium:tts:pocket:es-marius", + "language": "es", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "marius", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Mary (Spanish)", + "name": "pocket-es-mary", + "originalName": "mary", + "voiceURI": "urn:readium:tts:pocket:es-mary", + "language": "es", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "mary", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Michael (Spanish)", + "name": "pocket-es-michael", + "originalName": "michael", + "voiceURI": "urn:readium:tts:pocket:es-michael", + "language": "es", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "michael", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Paul (Spanish)", + "name": "pocket-es-paul", + "originalName": "paul", + "voiceURI": "urn:readium:tts:pocket:es-paul", + "language": "es", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "paul", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Peter Yearsley (Spanish)", + "name": "pocket-es-peter_yearsley", + "originalName": "peter_yearsley", + "voiceURI": "urn:readium:tts:pocket:es-peter_yearsley", + "language": "es", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "peter_yearsley", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Rafael (Spanish)", + "name": "pocket-es-rafael", + "originalName": "rafael", + "voiceURI": "urn:readium:tts:pocket:es-rafael", + "language": "es", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "rafael", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Stuart Bell (Spanish)", + "name": "pocket-es-stuart_bell", + "originalName": "stuart_bell", + "voiceURI": "urn:readium:tts:pocket:es-stuart_bell", + "language": "es", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "stuart_bell", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Vera (Spanish)", + "name": "pocket-es-vera", + "originalName": "vera", + "voiceURI": "urn:readium:tts:pocket:es-vera", + "language": "es", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "vera", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Alba (Portuguese)", + "name": "pocket-pt-alba", + "originalName": "alba", + "voiceURI": "urn:readium:tts:pocket:pt-alba", + "language": "pt", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "alba", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Anna (Portuguese)", + "name": "pocket-pt-anna", + "originalName": "anna", + "voiceURI": "urn:readium:tts:pocket:pt-anna", + "language": "pt", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "anna", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Azelma (Portuguese)", + "name": "pocket-pt-azelma", + "originalName": "azelma", + "voiceURI": "urn:readium:tts:pocket:pt-azelma", + "language": "pt", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "azelma", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Bill Boerst (Portuguese)", + "name": "pocket-pt-bill_boerst", + "originalName": "bill_boerst", + "voiceURI": "urn:readium:tts:pocket:pt-bill_boerst", + "language": "pt", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "bill_boerst", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Caro Davy (Portuguese)", + "name": "pocket-pt-caro_davy", + "originalName": "caro_davy", + "voiceURI": "urn:readium:tts:pocket:pt-caro_davy", + "language": "pt", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "caro_davy", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Charles (Portuguese)", + "name": "pocket-pt-charles", + "originalName": "charles", + "voiceURI": "urn:readium:tts:pocket:pt-charles", + "language": "pt", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "charles", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Cosette (Portuguese)", + "name": "pocket-pt-cosette", + "originalName": "cosette", + "voiceURI": "urn:readium:tts:pocket:pt-cosette", + "language": "pt", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "cosette", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Eponine (Portuguese)", + "name": "pocket-pt-eponine", + "originalName": "eponine", + "voiceURI": "urn:readium:tts:pocket:pt-eponine", + "language": "pt", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "eponine", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Estelle (Portuguese)", + "name": "pocket-pt-estelle", + "originalName": "estelle", + "voiceURI": "urn:readium:tts:pocket:pt-estelle", + "language": "pt", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "estelle", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Eve (Portuguese)", + "name": "pocket-pt-eve", + "originalName": "eve", + "voiceURI": "urn:readium:tts:pocket:pt-eve", + "language": "pt", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "eve", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Fantine (Portuguese)", + "name": "pocket-pt-fantine", + "originalName": "fantine", + "voiceURI": "urn:readium:tts:pocket:pt-fantine", + "language": "pt", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "fantine", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "George (Portuguese)", + "name": "pocket-pt-george", + "originalName": "george", + "voiceURI": "urn:readium:tts:pocket:pt-george", + "language": "pt", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "george", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Giovanni (Portuguese)", + "name": "pocket-pt-giovanni", + "originalName": "giovanni", + "voiceURI": "urn:readium:tts:pocket:pt-giovanni", + "language": "pt", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "giovanni", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Jane (Portuguese)", + "name": "pocket-pt-jane", + "originalName": "jane", + "voiceURI": "urn:readium:tts:pocket:pt-jane", + "language": "pt", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "jane", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Javert (Portuguese)", + "name": "pocket-pt-javert", + "originalName": "javert", + "voiceURI": "urn:readium:tts:pocket:pt-javert", + "language": "pt", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "javert", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Jean (Portuguese)", + "name": "pocket-pt-jean", + "originalName": "jean", + "voiceURI": "urn:readium:tts:pocket:pt-jean", + "language": "pt", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "jean", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Juergen (Portuguese)", + "name": "pocket-pt-juergen", + "originalName": "juergen", + "voiceURI": "urn:readium:tts:pocket:pt-juergen", + "language": "pt", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "juergen", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Lola (Portuguese)", + "name": "pocket-pt-lola", + "originalName": "lola", + "voiceURI": "urn:readium:tts:pocket:pt-lola", + "language": "pt", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "lola", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Marius (Portuguese)", + "name": "pocket-pt-marius", + "originalName": "marius", + "voiceURI": "urn:readium:tts:pocket:pt-marius", + "language": "pt", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "marius", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Mary (Portuguese)", + "name": "pocket-pt-mary", + "originalName": "mary", + "voiceURI": "urn:readium:tts:pocket:pt-mary", + "language": "pt", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "mary", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Michael (Portuguese)", + "name": "pocket-pt-michael", + "originalName": "michael", + "voiceURI": "urn:readium:tts:pocket:pt-michael", + "language": "pt", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "michael", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Paul (Portuguese)", + "name": "pocket-pt-paul", + "originalName": "paul", + "voiceURI": "urn:readium:tts:pocket:pt-paul", + "language": "pt", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "paul", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Peter Yearsley (Portuguese)", + "name": "pocket-pt-peter_yearsley", + "originalName": "peter_yearsley", + "voiceURI": "urn:readium:tts:pocket:pt-peter_yearsley", + "language": "pt", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "peter_yearsley", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Rafael (Portuguese)", + "name": "pocket-pt-rafael", + "originalName": "rafael", + "voiceURI": "urn:readium:tts:pocket:pt-rafael", + "language": "pt", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "rafael", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Stuart Bell (Portuguese)", + "name": "pocket-pt-stuart_bell", + "originalName": "stuart_bell", + "voiceURI": "urn:readium:tts:pocket:pt-stuart_bell", + "language": "pt", + "gender": "male", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "stuart_bell", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + }, + { + "source": "json", + "label": "Vera (Portuguese)", + "name": "pocket-pt-vera", + "originalName": "vera", + "voiceURI": "urn:readium:tts:pocket:pt-vera", + "language": "pt", + "gender": "female", + "quality": "normal", + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "vera", + "sampleRate": 24000, + "mimeTypes": [ + "audio/mpeg", + "audio/wav", + "audio/ogg" + ], + "pitchControl": false + } +] \ No newline at end of file diff --git a/app/domain/enums.py b/app/domain/enums.py index 2a2a68a..e1c336e 100644 --- a/app/domain/enums.py +++ b/app/domain/enums.py @@ -14,15 +14,8 @@ class Gender(StrEnum): class Quality(StrEnum): + VERY_LOW = "veryLow" LOW = "low" - MEDIUM = "medium" + NORMAL = "normal" HIGH = "high" - PREMIUM = "premium" - - -class ProviderId(StrEnum): - POCKET = "pocket" - KOKORO = "kokoro" - ELEVENLABS = "elevenlabs" - AZURE = "azure" - FAKE = "fake" + VERY_HIGH = "veryHigh" diff --git a/app/drivers/__init__.py b/app/drivers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/drivers/ffmpeg.py b/app/drivers/ffmpeg.py new file mode 100644 index 0000000..1df456c --- /dev/null +++ b/app/drivers/ffmpeg.py @@ -0,0 +1,72 @@ +import asyncio +import shutil + +from app.api.errors import ProviderError + +# (ffmpeg_args, content-type) +_FORMATS: dict[str, tuple[list[str], str]] = { + "mp3": (["-f", "mp3"], "audio/mpeg"), + "opus": (["-f", "ogg", "-c:a", "libopus"], "audio/ogg"), + "wav": ([], "audio/wav"), # wav bypasses ffmpeg; entry here makes content_type() safe +} + + +def is_available(ffmpeg_bin: str = "ffmpeg") -> bool: + return shutil.which(ffmpeg_bin) is not None + + +def content_type(format: str) -> str: + return _FORMATS[format][1] + + +async def encode( + pcm: bytes, + sample_rate: int, + format: str, + ffmpeg_bin: str = "ffmpeg", + bitrate: int | None = None, +) -> bytes: + fmt_args, _ = _FORMATS[format] + bitrate_args = ["-b:a", f"{bitrate}k"] if bitrate else [] + + cmd = [ + ffmpeg_bin, + "-f", + "s16le", + "-ar", + str(sample_rate), + "-ac", + "1", + "-i", + "pipe:0", + *fmt_args, + *bitrate_args, + "-loglevel", + "error", + "pipe:1", + ] + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except OSError as exc: + raise ProviderError("ffmpeg launch failed", detail=str(exc)) from exc + + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(input=pcm), timeout=30) + except TimeoutError as exc: + proc.kill() + await proc.communicate() + raise ProviderError("ffmpeg encoding timed out") from exc + + if proc.returncode != 0: + raise ProviderError( + "ffmpeg encoding failed", + detail=stderr.decode(errors="replace")[:300], + ) + + return stdout diff --git a/app/logging/config.py b/app/logging/config.py index 9b4a9d9..a20bb60 100644 --- a/app/logging/config.py +++ b/app/logging/config.py @@ -1,8 +1,10 @@ import json import logging +import logging.handlers import sys import time import uuid +from queue import SimpleQueue from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint from starlette.requests import Request @@ -27,13 +29,24 @@ def format(self, record: logging.LogRecord) -> str: return json.dumps(log) -def configure_logging(level: str = "INFO") -> None: - handler = logging.StreamHandler(sys.stdout) - handler.setFormatter(JsonFormatter()) - logging.root.handlers = [handler] +def configure_logging(level: str = "INFO") -> logging.handlers.QueueListener: + stream_handler = logging.StreamHandler(sys.stdout) + stream_handler.setFormatter(JsonFormatter()) + + # QueueHandler enqueues records instantly (non-blocking on the event loop). + # QueueListener drains the queue in a background daemon thread. + log_queue: SimpleQueue[logging.LogRecord] = SimpleQueue() + queue_handler = logging.handlers.QueueHandler(log_queue) + + listener = logging.handlers.QueueListener(log_queue, stream_handler, respect_handler_level=True) + listener.start() + + logging.root.handlers = [queue_handler] logging.root.setLevel(getattr(logging, level.upper(), logging.INFO)) logging.getLogger("uvicorn.access").propagate = False + return listener + _access_logger = logging.getLogger("app.access") @@ -45,22 +58,17 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) - start = time.monotonic() response = await call_next(request) latency_ms = round((time.monotonic() - start) * 1000, 1) - record = logging.LogRecord( - name="app.access", - level=logging.INFO, - pathname="", - lineno=0, - msg="", - args=(), - exc_info=None, + _access_logger.info( + "", + extra={ + "fields": { + "request_id": request_id, + "method": request.method, + "path": request.url.path, + "status": response.status_code, + "latency_ms": latency_ms, + } + }, ) - record.fields = { - "request_id": request_id, - "method": request.method, - "path": request.url.path, - "status": response.status_code, - "latency_ms": latency_ms, - } - _access_logger.handle(record) response.headers["X-Request-Id"] = request_id return response diff --git a/app/main.py b/app/main.py index e731b36..30d79cf 100644 --- a/app/main.py +++ b/app/main.py @@ -11,24 +11,27 @@ from app.core.registry import ProviderRegistry from app.core.voice_catalog import VoiceCatalog from app.logging.config import RequestLoggingMiddleware, configure_logging -from app.providers.fake import FakeProvider +from app.providers.pocket_tts import PocketTTSProvider def _build_registry() -> ProviderRegistry: registry = ProviderRegistry() enabled = {p.strip() for p in settings.enabled_providers.split(",")} - if "fake" in enabled: - registry.register(FakeProvider()) - # Phase 2+: pocket, kokoro, elevenlabs, azure registered here + if "pocket" in enabled: + registry.register(PocketTTSProvider()) return registry @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: - configure_logging(settings.log_level) + log_listener = configure_logging(settings.log_level) init_semaphore(settings.max_concurrent_syntheses) registry = _build_registry() + + for provider in registry.all(): + await provider.load() + catalog = VoiceCatalog(registry) await catalog.load() @@ -37,6 +40,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.state.ready = True yield app.state.ready = False + log_listener.stop() # flush remaining records before process exits def create_app() -> FastAPI: diff --git a/app/providers/base.py b/app/providers/base.py index d018937..a94a888 100644 --- a/app/providers/base.py +++ b/app/providers/base.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod from collections.abc import Sequence +from typing import ClassVar from app.schemas.audio import AudioResult, SynthesisParams from app.schemas.voice import Voice @@ -8,11 +9,37 @@ class TTSProvider(ABC): """Uniform interface for every TTS backend (local model or proxied HTTP).""" - id: str + id: ClassVar[str] + + # BCP-47 language prefixes this provider can serve. + # Empty (default) = language-agnostic; list_voices() returns all voices unfiltered. + supported_languages: ClassVar[frozenset[str]] = frozenset() + + # True when synthesize() populates AudioResult.boundaries with word timing marks. + supports_boundaries: ClassVar[bool] = False + + def active_languages(self) -> frozenset[str]: + """Intersection of global LANGUAGES config and this provider's supported set. + Returns empty frozenset when supported_languages is unset (= no filtering).""" + if not self.supported_languages: + return frozenset() + from app.config.settings import settings + + configured = {lang.split("-")[0].lower() for lang in settings.language_list} + supported = {lang.split("-")[0].lower() for lang in self.supported_languages} + return frozenset(supported & configured) @abstractmethod + async def _all_voices(self) -> Sequence[Voice]: + """All voices this provider knows about, regardless of language config.""" + async def list_voices(self) -> Sequence[Voice]: - """Return this provider's voices in the Readium Voice shape.""" + """Voices filtered to active_languages(). Providers do NOT override this.""" + voices = await self._all_voices() + if not self.supported_languages: + return voices # language-agnostic provider: return all + active = self.active_languages() + return [v for v in voices if v.language.split("-")[0].lower() in active] @abstractmethod async def synthesize(self, params: SynthesisParams) -> AudioResult: @@ -21,6 +48,9 @@ async def synthesize(self, params: SynthesisParams) -> AudioResult: Implementations MUST offload CPU-bound work off the event loop via run_inference(). """ + async def load(self) -> None: + """Called once at startup. Override to load models or warm up connections.""" + async def health(self) -> bool: """Optional readiness signal. Override to check model loaded / remote reachable.""" return True diff --git a/app/providers/fake.py b/app/providers/fake.py index d5e13b1..d56496f 100644 --- a/app/providers/fake.py +++ b/app/providers/fake.py @@ -1,60 +1,64 @@ +import functools import math import struct from collections.abc import Sequence from app.core.concurrency import run_inference -from app.domain.enums import Gender, ProviderId, Quality +from app.domain.enums import Gender, Quality from app.providers.base import TTSProvider from app.schemas.audio import AudioResult, SynthesisParams from app.schemas.voice import Voice -_SAMPLE_RATE = 22050 -_DURATION = 0.5 # seconds +_SAMPLE_RATE = 24000 # matches PocketTTS native output _FREQ = 440.0 # Hz - -def _generate_tone(sample_rate: int = _SAMPLE_RATE, duration: float = _DURATION) -> bytes: - n = int(sample_rate * duration) - samples = [int(32767 * math.sin(2 * math.pi * _FREQ * i / sample_rate)) for i in range(n)] - return struct.pack(f"<{n}h", *samples) - - _VOICES: list[Voice] = [ Voice( + source="json", label="Fake Voice (English)", - voiceURI="urn:readium:tts:fake:en-US-standard", name="fake-en-US", + originalName="fake-en-US", + voiceURI="urn:readium:tts:fake:en-US-standard", language="en-US", gender=Gender.NEUTRAL, - offlineAvailability=True, - quality=Quality.MEDIUM, - provider=ProviderId.FAKE, + quality=Quality.NORMAL, + preloaded=True, + provider="fake", engineVoiceId="fake-en", sampleRate=_SAMPLE_RATE, - mimeTypes=["audio/wav"], + mimeTypes=["audio/mpeg", "audio/wav", "audio/ogg"], ), Voice( + source="json", label="Fake Voice (French)", - voiceURI="urn:readium:tts:fake:fr-FR-standard", name="fake-fr-FR", + originalName="fake-fr-FR", + voiceURI="urn:readium:tts:fake:fr-FR-standard", language="fr-FR", gender=Gender.NEUTRAL, - offlineAvailability=True, - quality=Quality.MEDIUM, - provider=ProviderId.FAKE, + quality=Quality.NORMAL, + preloaded=True, + provider="fake", engineVoiceId="fake-fr", sampleRate=_SAMPLE_RATE, - mimeTypes=["audio/wav"], + mimeTypes=["audio/mpeg", "audio/wav", "audio/ogg"], ), ] +def _generate_tone(sample_rate: int, duration: float) -> bytes: + n = int(sample_rate * duration) + samples = [int(32767 * math.sin(2 * math.pi * _FREQ * i / sample_rate)) for i in range(n)] + return struct.pack(f"<{n}h", *samples) + + class FakeProvider(TTSProvider): id = "fake" - async def list_voices(self) -> Sequence[Voice]: + async def _all_voices(self) -> Sequence[Voice]: return _VOICES async def synthesize(self, params: SynthesisParams) -> AudioResult: - pcm = await run_inference(_generate_tone) + duration = max(0.1, len(params.text) * 0.05) / max(0.1, params.speed) + pcm = await run_inference(functools.partial(_generate_tone, _SAMPLE_RATE, duration)) return AudioResult(pcm=pcm, sample_rate=_SAMPLE_RATE) diff --git a/app/providers/pocket_tts.py b/app/providers/pocket_tts.py new file mode 100644 index 0000000..09e5d13 --- /dev/null +++ b/app/providers/pocket_tts.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import json +import logging +import threading +from collections.abc import Sequence +from pathlib import Path +from re import sub as re_sub +from typing import Any, ClassVar + +import anyio + +from app.api.errors import ProviderError +from app.core.concurrency import run_inference +from app.providers.base import TTSProvider +from app.schemas.audio import AudioResult, SynthesisParams +from app.schemas.voice import Voice + +logger = logging.getLogger(__name__) + +_VOICES_PATH = Path(__file__).parent.parent / "data" / "voices" / "pocket" / "voices.json" + +# BCP-47 prefix → pocket-tts language identifier +_LANG_MODEL: dict[str, str] = { + "en": "english", + "fr": "french_24l", + "it": "italian", + "de": "german_24l", + "es": "spanish_24l", + "pt": "portuguese", +} + + +def _strip_ssml(text: str) -> str: + return re_sub(r"<[^>]+>", "", text).strip() + + +class PocketTTSProvider(TTSProvider): + id = "pocket" + supported_languages: ClassVar[frozenset[str]] = frozenset(_LANG_MODEL.keys()) + + def __init__(self) -> None: + self._models: dict[str, Any] = {} # lang_key → TTSModel + self._model_locks: dict[str, threading.Lock] = {} # per-model lock; not thread-safe + self._voice_states: dict[str, Any] = {} # voiceURI → voice state + self._voice_lang: dict[str, str] = {} # voiceURI → lang_key + self._voices: list[Voice] = [] + self._ready = False + + async def load(self) -> None: + self._ready = await anyio.to_thread.run_sync(self._load_sync) + + def _load_sync(self) -> bool: + import torch + from pocket_tts import TTSModel + + torch.set_num_threads(1) + + enabled = self.active_languages() + if not enabled: + logger.warning("No PocketTTS-supported languages in LANGUAGES config") + return False + + raw = json.loads(_VOICES_PATH.read_text()) + self._voices = [ + Voice(**{**v, "boundary": self.supports_boundaries}) + for v in raw + if v["language"].split("-")[0].lower() in enabled + ] + + lang_voices: dict[str, list[Voice]] = {} + for voice in self._voices: + key = voice.language.split("-")[0].lower() + lang_voices.setdefault(key, []).append(voice) + self._voice_lang[voice.voiceURI] = key + + for lang_key, voices in lang_voices.items(): + model_id = _LANG_MODEL[lang_key] + logger.info("Loading PocketTTS model: %s ...", model_id) + model: Any = TTSModel.load_model(language=model_id) + model.eval() + self._models[lang_key] = model + self._model_locks[lang_key] = threading.Lock() + logger.info( + "Loaded %s (sample_rate=%d Hz); warming %d voice states...", + model_id, + model.sample_rate, + len(voices), + ) + for voice in voices: + self._voice_states[voice.voiceURI] = model.get_state_for_audio_prompt( + voice.engineVoiceId + ) + + logger.info( + "PocketTTS ready — %d voices across %d language models", + len(self._voices), + len(self._models), + ) + return True + + async def _all_voices(self) -> Sequence[Voice]: + return self._voices + + async def synthesize(self, params: SynthesisParams) -> AudioResult: + if params.voice_uri not in self._voice_states: + raise ProviderError(f"Unknown PocketTTS voice: {params.voice_uri}") + + text = _strip_ssml(params.text) if params.ssml else params.text + if not text: + raise ProviderError("Text is empty after stripping SSML tags") + + if params.speed != 1.0 or params.pitch is not None: + logger.debug( + "PocketTTS ignores speed/pitch (not supported); speed=%.2f pitch=%s", + params.speed, + params.pitch, + ) + + lang_key = self._voice_lang[params.voice_uri] + model = self._models[lang_key] + lock = self._model_locks[lang_key] + state = self._voice_states[params.voice_uri] + sample_rate: int = model.sample_rate + + def _infer() -> bytes: + import numpy as np + import torch + + try: + with lock, torch.no_grad(): + # copy_state=True (default) preserves the cached voice state for reuse. + # lock serializes calls per language model — generate_audio is not thread-safe. + audio = model.generate_audio(state, text) + except (ValueError, RuntimeError) as exc: + raise ProviderError(f"PocketTTS generation failed: {exc}") from exc + + # generate_audio returns [samples] (1D) for mono; guard against [channels, samples] too + pcm_tensor = audio[0] if audio.ndim == 2 else audio + pcm_array = pcm_tensor.numpy() + return bytes((pcm_array * 32767).clip(-32768, 32767).astype(np.int16).tobytes()) + + pcm = await run_inference(_infer) + return AudioResult(pcm=pcm, sample_rate=sample_rate) + + async def health(self) -> bool: + return self._ready diff --git a/app/schemas/audio.py b/app/schemas/audio.py index 7658ea3..2d058c6 100644 --- a/app/schemas/audio.py +++ b/app/schemas/audio.py @@ -1,16 +1,23 @@ +from typing import Literal + from pydantic import BaseModel class TimingMark(BaseModel): - offset: float # seconds from audio start - text: str # word or sentence text + """Mirrors the Web Speech API SpeechSynthesisEvent boundary fields. + charIndex + charLength index into the original SynthesizeRequest.text.""" + + name: Literal["word", "sentence"] = "word" + charIndex: int # character offset of word start in utterance text + charLength: int # character length of the word span + elapsedTime: float # seconds from audio start when word begins class SynthesisParams(BaseModel): text: str ssml: bool language: str | None - engineVoiceId: str + voice_uri: str speed: float pitch: float | None prev_utterance: str | None = None @@ -18,8 +25,6 @@ class SynthesisParams(BaseModel): class AudioResult(BaseModel): - pcm: bytes | None = None # raw PCM; None when encoded is set - encoded: bytes | None = None # pre-encoded bytes (commercial providers) - format: str | None = None # e.g. "mp3", "wav"; set when encoded is used + pcm: bytes sample_rate: int - timing_marks: list[TimingMark] | None = None # scaffolded; unused until Phase 3+ + boundaries: list[TimingMark] = [] # empty when provider doesn't support word timing diff --git a/app/schemas/utterance.py b/app/schemas/utterance.py index dee5656..74ebb3c 100644 --- a/app/schemas/utterance.py +++ b/app/schemas/utterance.py @@ -1,8 +1,18 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field from app.domain.enums import AudioFormat +class OutputConfig(BaseModel): + """Requested output parameters — nested under 'output' in the API body.""" + + format: AudioFormat = AudioFormat.MP3 + bitrate: int | None = None # kbps; only meaningful for mp3/opus + sample_rate: int | None = None + speed: float = 1.0 + pitch: float | None = None + + class Utterance(BaseModel): """Mirrors ReadiumSpeechUtterance.""" @@ -14,11 +24,8 @@ class Utterance(BaseModel): class SynthesizeRequest(Utterance): voice: str # voiceURI — required - format: AudioFormat = AudioFormat.MP3 - sampleRate: int | None = None - bitrate: int | None = None # kbps; meaningful for mp3/opus only - speed: float = 1.0 # rate multiplier - pitch: float | None = None - prev_utterance: str | None = None # preceding plain-text for prosody context - next_utterance: str | None = None # following plain-text for prosody context - publication_id: str | None = None # URI/ID for cache-key scoping (Redis in v2) + prev_utterance: str | None = None # preceding text/ID for prosody context + next_utterance: str | None = None # following text/ID for prosody context + boundary: bool = False # if true, response is JSON with base64 audio + timing marks + output: OutputConfig = Field(default_factory=OutputConfig) + publication_id: str | None = None # server extension: cache-key scope diff --git a/app/schemas/voice.py b/app/schemas/voice.py index a7fd05f..d01e950 100644 --- a/app/schemas/voice.py +++ b/app/schemas/voice.py @@ -1,24 +1,36 @@ +from typing import Literal + from pydantic import BaseModel -from app.domain.enums import Gender, ProviderId, Quality +from app.domain.enums import Gender, Quality class Voice(BaseModel): - # Readium IVoices-aligned fields + # --- Readium ReadiumSpeechVoice-aligned fields --- + source: Literal["json", "browser"] = "json" label: str - voiceURI: str name: str + originalName: str + voiceURI: str language: str # BCP-47 + localizedName: str | None = None # "android" | "apple" + altNames: list[str] | None = None + altLanguage: str | None = None + otherLanguages: list[str] | None = None + multiLingual: bool | None = None gender: Gender | None = None - age: str | None = None - offlineAvailability: bool = False + children: bool | None = None quality: Quality | None = None pitchControl: bool = False - recommendedPitch: float | None = None - recommendedRate: float | None = None + pitch: float | None = None + rate: float | None = None + preloaded: bool = False + nativeID: str | list[str] | None = None + note: str | None = None - # Server extensions - provider: ProviderId + # --- server extensions (not in ReadiumSpeechVoice) --- + provider: str engineVoiceId: str sampleRate: int mimeTypes: list[str] + boundary: bool = False # true when provider supports word-level timing marks diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..d30773a --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,11 @@ +# Dev override: mount source for hot-reload and live test edits. +# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build +services: + app: + restart: "no" + stop_grace_period: 5s + volumes: + - ./app:/app/app + - ./tests:/app/tests + environment: + - RELOAD=true diff --git a/docker-compose.yml b/docker-compose.yml index 01fe7fc..9bcc4a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,14 +5,15 @@ services: - "${PORT:-8000}:8000" env_file: - .env + environment: + - HF_TOKEN=${HF_TOKEN:-} volumes: - - weights:/app/assets/weights + - weights_cache:/weights networks: - speech-net restart: unless-stopped + stop_grace_period: 5s - # Optional NGINX reverse proxy — only needed for VPS/self-hosted. - # Enable with: docker compose --profile nginx up nginx: image: nginx:alpine profiles: [nginx] @@ -27,7 +28,7 @@ services: - speech-net volumes: - weights: + weights_cache: networks: speech-net: diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..6d82186 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,469 @@ +# Readium Speech Server — API Reference & Design Notes + +Everything implemented in the `feature/pocket-tts` branch. Covers the full +request/response contract, field-by-field meaning, and the reasoning behind +each design decision. + +--- + +## Table of Contents + +1. [POST /v1/synthesize — Request](#post-v1synthesize--request) +2. [POST /v1/synthesize — Response (audio)](#post-v1synthesize--response-audio) +3. [POST /v1/synthesize — Response (boundary)](#post-v1synthesize--response-boundary) +4. [GET /v1/voices — Response](#get-v1voices--response) +5. [The `id` field and UUID v7](#the-id-field-and-uuid-v7) +6. [Word Boundaries — deep dive](#word-boundaries--deep-dive) +7. [Language Filtering](#language-filtering) +8. [Provider Capabilities](#provider-capabilities) +9. [Future: MathML](#future-mathml) + +--- + +## POST /v1/synthesize — Request + +```json +{ + "id": "urn:uuid:019f178c-cc7c-7bb3-a39b-d185f43d3cc4", + "text": "Ceci est un test.", + "ssml": false, + "language": "fr", + "voice": "urn:readium:tts:pocket:fr-estelle", + "prev_utterance": "La nuit était sombre.", + "next_utterance": "La pièce était froide.", + "publication_id": "urn:isbn:9780000000000", + "boundary": true, + "output": { + "format": "mp3", + "bitrate": 64, + "sample_rate": null, + "speed": 1.0, + "pitch": null + } +} +``` + +### Top-level fields + +| Field | Type | Required | Default | Meaning | +|---|---|---|---|---| +| `id` | `string \| null` | No | `null` | Client-generated utterance identifier. UUID v7 URN. Used for correlation, idempotency, future caching. Currently received but not used by server. | +| `text` | `string` | **Yes** | — | The text to synthesize. Max 2000 chars (configurable via `MAX_TEXT_LENGTH`). Must not be empty/whitespace. | +| `ssml` | `boolean` | No | `false` | If `true`, `text` contains SSML markup. PocketTTS strips tags before synthesis — no SSML-aware prosody today. | +| `language` | `string \| null` | No | `null` | Language hint (`"fr"`, `"en"`). Used for provider routing in future multi-language requests. | +| `voice` | `string` | **Yes** | — | `voiceURI` from the voices list. Must exactly match a registered voice. Returns 404 if not found. | +| `prev_utterance` | `string \| null` | No | `null` | Text of the sentence before this one. Passed to provider for prosody context (natural speech flow). PocketTTS ignores today — reserved for Kokoro/commercial providers. | +| `next_utterance` | `string \| null` | No | `null` | Text of the sentence after this one. Same purpose. | +| `publication_id` | `string \| null` | No | `null` | Server extension. Identifies the ebook/document. Future: scopes the in-memory cache so utterances from different books don't collide. | +| `boundary` | `boolean` | No | `false` | If `true`, response is JSON with base64 audio + word timing marks instead of binary audio. See [boundary section](#post-v1synthesize--response-boundary). | + +### `output` object + +`output` is **optional** — omit it entirely to get mp3 at defaults. + +Why nested? The Readium Speech API spec groups audio parameters under `output` +to distinguish them from utterance properties (`text`, `language`, `voice`). +Flat structure would mix "what to say" with "how to encode it". + +| Field | Type | Default | Meaning | +|---|---|---|---| +| `format` | `"mp3" \| "wav" \| "opus"` | `"mp3"` | Output audio format. `wav` = raw PCM in RIFF container (no ffmpeg needed, fastest). `mp3` and `opus` go through ffmpeg. | +| `bitrate` | `integer \| null` | `null` | kbps for mp3/opus encoding. `null` = ffmpeg default (~128 kbps mp3, ~64 kbps opus). Ignored for `wav`. | +| `sample_rate` | `integer \| null` | `null` | Output sample rate. `null` = native model rate (24000 Hz for PocketTTS). Resampling via ffmpeg if set differently. | +| `speed` | `float` | `1.0` | Playback speed multiplier. `0.5` = half speed, `2.0` = double. PocketTTS ignores today (logs warning). Passed through for providers that support it. | +| `pitch` | `float \| null` | `null` | Pitch adjustment. PocketTTS ignores today. Passed through for providers that support it. | + +--- + +## POST /v1/synthesize — Response (audio) + +When `boundary: false` (default). Returns **binary audio** directly. + +``` +HTTP 200 OK +Content-Type: audio/mpeg (mp3) + audio/wav (wav) + audio/ogg (opus) +Content-Disposition: attachment; filename=speech.mp3 +``` + +Body = raw audio bytes. No JSON wrapper. + +### Error responses + +All errors return JSON: + +```json +{ + "error": { + "code": "voice_not_found", + "message": "Voice 'urn:unknown' not found.", + "detail": null + } +} +``` + +| Status | `error.code` | Cause | +|---|---|---| +| 400 | `validation_failed` | Empty or whitespace `text` | +| 404 | `voice_not_found` | `voice` URI not in registry | +| 413 | `payload_too_large` | `text` exceeds `MAX_TEXT_LENGTH` | +| 422 | *(Pydantic detail)* | Schema error (wrong type, missing `voice`, invalid `format`) | + +--- + +## POST /v1/synthesize — Response (boundary) + +When `boundary: true`. Returns **JSON** regardless of `output.format`. + +```json +{ + "audio": "UklGRiQAAABXQVZFZm10IBAAAA...", + "format": "mp3", + "boundaries": [ + { "name": "word", "charIndex": 0, "charLength": 4, "elapsedTime": 0.0 }, + { "name": "word", "charIndex": 5, "charLength": 3, "elapsedTime": 0.31 }, + { "name": "word", "charIndex": 9, "charLength": 2, "elapsedTime": 0.52 }, + { "name": "word", "charIndex": 12, "charLength": 5, "elapsedTime": 0.68 } + ] +} +``` + +### Response fields + +| Field | Type | Meaning | +|---|---|---| +| `audio` | `string` | Base64-encoded audio in the requested `output.format`. Decode to bytes to play. | +| `format` | `string` | Format of the encoded audio (`"mp3"`, `"wav"`, `"opus"`). | +| `boundaries` | `array \| null` | Word timing marks. **`null`** = provider does not support word boundaries. **`[]`** = supported but no marks produced. **`[{...}]`** = marks present. | + +### Why `null` not `[]` for unsupported? + +`[]` is ambiguous — could mean "supported but no words" (edge case) or "not supported." +`null` is an unambiguous sentinel: this provider cannot produce timing marks. +Client does one null-check, no invented boolean flags needed. + +``` +boundaries: null → feature not available for this voice/provider +boundaries: [] → available but nothing produced (shouldn't happen in practice) +boundaries: [...] → available + marks +``` + +### `TimingMark` fields + +Mirrors the **Web Speech API `SpeechSynthesisEvent`** boundary event fields exactly. +This is intentional — Readium clients already handle Web Speech API events; same field +names = zero translation layer. + +| Field | Type | Web Speech API equivalent | Meaning | +|---|---|---|---| +| `name` | `"word" \| "sentence"` | `event.name` | Type of boundary. Currently always `"word"`. | +| `charIndex` | `integer` | `event.charIndex` | Character offset in the original `text` string where this word starts. | +| `charLength` | `integer` | `event.charLength` | Character count of the word. `text.slice(charIndex, charIndex + charLength)` = the word. | +| `elapsedTime` | `float` | `event.elapsedTime` | Seconds from audio start when this word begins. | + +**Why no `text` field in the mark?** The original `text` is already in the request. +Client reconstructs the word: `text.substring(charIndex, charIndex + charLength)`. +Embedding the word string would duplicate data and create mismatch risk if text is +preprocessed differently. + +**Why no `end` field?** End time = next mark's `elapsedTime` (or total audio duration +for the last word). Web Speech API omits it for the same reason. + +### Boundary support by provider + +| Provider | `supports_boundaries` | Notes | +|---|---|---| +| PocketTTS | `false` | `generate_audio()` returns PCM only — no timing data | +| ElevenLabs (future) | `true` | Returns character-level `alignment` arrays — aggregate to words | +| Azure Speech (future) | `true` | `WordBoundary` SDK events with tick offsets — convert to seconds | +| Web Speech API | `true` (browser-native) | Native `boundary` events | + +For ElevenLabs: their response has `characters[]`, `character_start_times_seconds[]`, +`character_end_times_seconds[]`. Walk the arrays, emit a new `TimingMark` each time +a space or punctuation boundary is crossed. + +--- + +## GET /v1/voices — Response + +```json +[ + { + "source": "json", + "label": "Alba (English)", + "name": "pocket-en-alba", + "originalName": "alba", + "voiceURI": "urn:readium:tts:pocket:en-alba", + "language": "en", + "gender": "female", + "quality": "normal", + "pitchControl": false, + "preloaded": true, + "provider": "pocket", + "engineVoiceId": "alba", + "sampleRate": 24000, + "mimeTypes": ["audio/mpeg", "audio/wav", "audio/ogg"], + "boundary": false + } +] +``` + +Null-valued optional fields (`localizedName`, `altNames`, `altLanguage`, `otherLanguages`, `multiLingual`, `children`, `pitch`, `rate`, `nativeID`, `note`) are omitted from the response. + +### Field groups + +**Readium `ReadiumSpeechVoice`-aligned fields** (standard — client expects these): + +| Field | Meaning | +|---|---| +| `source` | Always `"json"` for server-hosted voices. `"browser"` = Web Speech API voice (client-side only). | +| `label` | Human-readable display name. Used in UI pickers. | +| `name` | Unique identifier for this voice within Readium ecosystem. | +| `originalName` | Raw engine voice ID as provided by the TTS engine. | +| `voiceURI` | **The key field** — send this in `SynthesizeRequest.voice`. Globally unique URI. | +| `language` | Language code (`"en"`, `"fr"`). Matches PocketTTS model names. Used for filtering and matching to book language. | +| `gender` | `"male"`, `"female"`, `"neutral"`, or `null`. | +| `quality` | `"veryLow"`, `"low"`, `"normal"`, `"high"`, `"veryHigh"`. PocketTTS voices = `"normal"`. | +| `preloaded` | `true` = model weights are in the image / downloaded to the weights volume, ready immediately. | +| `pitchControl` | `true` = provider accepts `output.pitch`. PocketTTS = `false`. | +| `pitch` / `rate` | Recommended defaults for this voice, if the engine specifies any. Usually `null`. | + +**Server extension fields** (not in Readium spec — added by this server): + +| Field | Meaning | +|---|---| +| `provider` | Which TTS backend serves this voice. `"pocket"` today. `"kokoro"`, `"elevenlabs"`, `"azure"` later. | +| `engineVoiceId` | Raw voice ID passed to the engine internally. Not for client use — opaque. | +| `sampleRate` | Native PCM sample rate of the model output in Hz. PocketTTS = `24000`. | +| `mimeTypes` | Audio formats this voice can produce. All voices support `["audio/mpeg", "audio/wav", "audio/ogg"]`. | +| `boundary` | **`true`** = this voice's provider supports word timing marks. Send `boundary: true` in synthesis requests. **`false`** = marks unavailable, response will have `boundaries: null`. | + +### Why `boundary` on the voice? + +Client checks `voice.boundary` **before** sending the synthesis request. +If `false`, client doesn't set `boundary: true` — saves a round trip and avoids +getting `null` back. If a client ignores the flag and sends `boundary: true` anyway, +the response carries `boundaries: null` to explain why marks are absent. + +--- + +## The `id` field and UUID v7 + +### Anatomy of the `id` + +``` +"id": "urn:uuid:019f178c-cc7c-7bb3-a39b-d185f43d3cc4" + ───┬─── ───┬─── ─────────────────────────────── + │ │ UUID itself (128 bits, 32 hex chars + 4 dashes) + │ └──── URN namespace: UUID (IANA-registered) + └───────────── URN scheme: a name, not a network address +``` + +**`urn:`** — Uniform Resource Name (RFC 8141). Persistent global identifier that +doesn't resolve to a URL. Unlike `https://`, it just *names* something uniquely and +permanently. Good for IDs that need to survive across systems without implying a +network location. + +**`uuid:`** — The IANA-registered URN namespace for UUIDs (RFC 4122). + +### UUID v7 + +The example ID uses **UUID version 7** (RFC 9562, 2024). + +``` +019f178c-cc7c-7bb3-a39b-d185f43d3cc4 +───────────────────────────────────── +First 48 bits = Unix timestamp in milliseconds +019f178c cc7c → 0x019f178ccc7c → 1750697878652 ms → 2025-06-23 ~17:57 UTC + +Version nibble = 7 (the "7" in "7bb3") + +Remaining bits = random +``` + +UUID version comparison: + +| Version | Year | Sortable? | Notes | +|---|---|---|---| +| v1 | 1997 | Yes | Encodes MAC address — privacy risk | +| v4 | 2003 | No | Pure random — most common today | +| v7 | 2024 | **Yes** | Time-ordered + random — preferred for new systems | + +v7 is preferred because database indexes on UUIDs don't fragment (sorted insert order), +and you can decode the creation timestamp from the ID itself. + +### What the server does with `id` today + +**Nothing.** Field is received, parsed into `Utterance.id`, never used. + +### What it's intended for + +1. **Correlation** — synthesis requests can return out of order. Client matches + audio response to book position using `id`. + +2. **Cache key** — `(publication_id, id, voice, format)` → cached audio bytes. + Same sentence + same voice = return cached bytes, skip synthesis. Defined in + Phase 3 / `design.md §17` as in-memory LRU cache. + +3. **Idempotency** — client retries failed request with same `id` → server detects + duplicate, returns cached result, doesn't synthesize twice. + +4. **Distributed tracing** — log `id` alongside errors, duration, provider name. + "Why did this utterance fail?" traceable across log lines. + +--- + +## Word Boundaries — deep dive + +### Why boundaries matter + +Readium reads ebooks aloud. As audio plays, the UI highlights the word currently +being spoken. To highlight correctly it needs to know: "at 0.31 seconds into this +audio clip, the third word starts." That's a timing mark. + +Without boundaries: audio plays but nothing highlights. Reader loses their place. + +### The Web Speech API model + +The browser's built-in TTS (`SpeechSynthesis`) fires a `boundary` event for each +word. Event fields: + +``` +charIndex integer Character offset in the utterance string +charLength integer Length of the word +elapsedTime float Seconds since speech started +name string "word" or "sentence" +``` + +MDN reference: https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesisUtterance/boundary_event + +Our `TimingMark` is a direct mapping. When a Readium client already handles Web Speech +API boundary events, it handles our server response with zero changes. + +### The ElevenLabs model + +ElevenLabs stream-with-timestamps returns character-level arrays: + +```json +{ + "alignment": { + "characters": ["H","e","l","l","o"," ","w","o","r","l","d"], + "character_start_times_seconds": [0.0, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, ...], + "character_end_times_seconds": [0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, ...] + } +} +``` + +ElevenLabs reference: https://elevenlabs.io/docs/api-reference/text-to-speech/stream-with-timestamps + +To convert to `TimingMark` array: walk `characters[]`, track `charIndex` from the +original text, emit a new mark each time a space or punctuation boundary is crossed, +set `elapsedTime` from `character_start_times_seconds[i]`. + +### PocketTTS limitation + +`pocket_tts.TTSModel.generate_audio(state, text)` returns a single `torch.Tensor` +of PCM samples. The entire sentence is synthesized in one pass. No per-word timing +information comes back from the model. + +Options to work around (not implemented): +- **Word-by-word synthesis** — split text on spaces, synthesize each word, measure + sample count → duration. Very slow (N model calls instead of 1). Approximate + (prosody changes at word boundaries vs. sentence context). +- **Forced alignment** — run a separate forced-alignment model (e.g. `montreal-forced-aligner`) + on the audio + transcript after synthesis. Adds latency, heavy dependency. + +Current behaviour: `boundary: true` with a PocketTTS voice returns `boundaries: null`. +`Voice.boundary = false` tells client not to ask. + +--- + +## Language Filtering + +### How it works + +`LANGUAGES` environment variable = comma-separated BCP-47 prefixes: + +``` +LANGUAGES=en,fr +``` + +PocketTTS declares: +```python +supported_languages = frozenset({"en", "fr", "it", "de", "es", "pt"}) +``` + +`active_languages()` returns the **intersection**: +``` +{"en", "fr", "it", "de", "es", "pt"} ∩ {"en", "fr"} = {"en", "fr"} +``` + +`list_voices()` filters to voices whose `language` BCP-47 prefix is in the active set: +- `"en"` → included +- `"fr"` → included +- `"it"` → excluded + +### RAM impact + +Each language model is ~240 MB. Loading 6 languages = ~1.4 GB RSS. +Default `LANGUAGES=en` = ~240 MB. Select only what you need. + +### FakeProvider and language filtering + +`FakeProvider.supported_languages = frozenset()` (empty = language-agnostic). +`active_languages()` returns empty frozenset when `supported_languages` is empty. +`list_voices()` returns all fake voices unfiltered. + +This means tests are never affected by `LANGUAGES` config — fake voices always appear. + +--- + +## Provider Capabilities + +Every provider declares its capabilities as class variables. Adding a new provider +never touches routes, synthesizer, or voice catalog — only the provider class itself +and the registry. + +```python +class TTSProvider(ABC): + id: ClassVar[str] + supported_languages: ClassVar[frozenset[str]] = frozenset() # empty = all + supports_boundaries: ClassVar[bool] = False +``` + +| Capability | Class var | Effect | +|---|---|---| +| Language scope | `supported_languages` | `list_voices()` filters automatically | +| Word boundaries | `supports_boundaries` | `Voice.boundary` set at load time; response `boundaries` is `null` vs array | + +When a future provider supports boundaries: + +```python +class ElevenLabsProvider(TTSProvider): + id = "elevenlabs" + supported_languages = frozenset({"en", "fr", "de", ...}) + supports_boundaries = True # ← flip this + + async def synthesize(self, params) -> AudioResult: + ... + return AudioResult(pcm=pcm, sample_rate=44100, boundaries=marks) # ← populate this +``` + +Voices for that provider automatically get `boundary: true` in the voice list. +Synthesis response carries real marks. No other code changes. + +--- + +## Future: MathML + +MathML markup in `text` is currently passed through as-is (treated as plain text, +tags will be spoken literally). This is a known gap. + +When **MathCAT** is integrated: +- Detect MathML in `text` (similar to `ssml: true` detection — look for `` root tag) +- MathCAT converts MathML → spoken natural language string +- Pass the resulting string to the TTS engine as plain text + +No API shape change needed. `text` stays `string`. MathML support is a +pre-processing concern inside the provider or synthesizer, not a schema concern. diff --git a/pyproject.toml b/pyproject.toml index 5cc5aa7..a88b095 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,8 @@ dependencies = [ "pydantic-settings>=2.0.0", "httpx>=0.27.0", "anyio>=4.0.0", + "torch>=2.5", + "pocket-tts>=0.1.0", ] [dependency-groups] @@ -35,6 +37,14 @@ markers = [ "slow: long-running tests", ] +[tool.uv.sources] +torch = { index = "pytorch-cpu" } + +[[tool.uv.index]] +name = "pytorch-cpu" +url = "https://download.pytorch.org/whl/cpu" +explicit = true + [tool.ruff] line-length = 100 target-version = "py312" diff --git a/scripts/configure.sh b/scripts/configure.sh new file mode 100755 index 0000000..80bcc18 --- /dev/null +++ b/scripts/configure.sh @@ -0,0 +1,451 @@ +#!/usr/bin/env bash +# configure.sh — Readium Speech Server setup & management wizard +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="$REPO_ROOT/.env" + +# ── Colours ─────────────────────────────────────────────────────────────────── +B=$'\033[1m' R=$'\033[0m' G=$'\033[32m' Y=$'\033[33m' C=$'\033[36m' RED=$'\033[31m' + +ok() { printf '%s✓%s %s\n' "$G" "$R" "$*"; } +warn() { printf '%s!%s %s\n' "$Y" "$R" "$*"; } +err() { printf '%s✗%s %s\n' "$RED" "$R" "$*" >&2; } +hdr() { printf '\n%s── %s%s\n\n' "$B" "$*" "$R"; } + +# ── Portable .env read/write ────────────────────────────────────────────────── +get_env() { + grep -E "^${1}=" "$ENV_FILE" 2>/dev/null | cut -d= -f2- || true +} + +set_env() { + local key="$1" val="$2" tmp + if [[ ! -f "$ENV_FILE" ]]; then + printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE" + return + fi + if grep -qE "^${key}=" "$ENV_FILE"; then + tmp=$(mktemp) + while IFS= read -r line; do + if [[ "$line" == "${key}="* ]]; then + printf '%s=%s\n' "$key" "$val" + else + printf '%s\n' "$line" + fi + done < "$ENV_FILE" > "$tmp" + mv "$tmp" "$ENV_FILE" + else + printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE" + fi +} + +# ── Language helpers ────────────────────────────────────────────────────────── +ALL_LANGS=(en fr it de es pt) + +lang_name() { + case "$1" in + en) echo "English" ;; fr) echo "French" ;; + it) echo "Italian" ;; de) echo "German" ;; + es) echo "Spanish" ;; pt) echo "Portuguese" ;; + *) echo "$1" ;; + esac +} + +current_langs() { + local raw + raw=$(get_env LANGUAGES) + echo "${raw:-en}" +} + +lang_list_to_set() { + local out="" p + IFS=',' read -ra parts <<< "$1" + for p in "${parts[@]}"; do + p="${p// /}" + if [[ -n "$p" ]] && ! echo ",${out}," | grep -qF ",${p},"; then + out="${out:+${out},}${p}" + fi + done + echo "$out" +} + +show_lang_menu() { + local current="$1" + printf ' Supported languages (PocketTTS — ~240 MB each):\n\n' + local i=1 + for code in "${ALL_LANGS[@]}"; do + local marker=" " + if echo "$current" | grep -qE "(^|,)${code}(,|$)"; then + marker="${G}✓${R}" + fi + printf ' %s %d) %s — %s\n' "$marker" "$i" "$code" "$(lang_name "$code")" + i=$((i+1)) + done + printf '\n' +} + +num_to_lang() { + local n="$1" i=1 + for code in "${ALL_LANGS[@]}"; do + [[ "$n" == "$i" ]] && { echo "$code"; return; } + i=$((i+1)) + done +} + +# ── Docker check ────────────────────────────────────────────────────────────── +require_docker() { + if ! command -v docker &>/dev/null; then + err "Docker is not installed." + printf ' Install: https://www.docker.com/products/docker-desktop\n\n' + exit 1 + fi +} + +# ══════════════════════════════════════════════════════════════════════════════ +# Actions +# ══════════════════════════════════════════════════════════════════════════════ + +do_first_setup() { + require_docker + + if [[ -f "$ENV_FILE" ]]; then + warn ".env already exists." + printf '\n 1) Overwrite\n 2) Backup (.env → .env.backup) then overwrite\n 3) Cancel\n\n' + printf 'Choice [default: 3]: ' + read -r CHOICE + case "${CHOICE:-3}" in + 1) ;; + 2) cp "$ENV_FILE" "${ENV_FILE}.backup" + ok "Backed up → .env.backup" ;; + *) printf 'Cancelled.\n'; return ;; + esac + fi + + hdr "Step 1/3 — Languages" + show_lang_menu "" + printf 'Enter numbers separated by spaces [default: 1 = English]: ' + read -r LANG_INPUT + LANG_INPUT="${LANG_INPUT:-1}" + + LANGS="" + for num in $LANG_INPUT; do + code=$(num_to_lang "$num") + if [[ -n "$code" ]]; then + LANGS="${LANGS:+${LANGS},}${code}" + else + warn "Unknown selection \"$num\" — skipped" + fi + done + [[ -z "$LANGS" ]] && LANGS="en" + LANGS=$(lang_list_to_set "$LANGS") + ok "Languages: $LANGS" + + hdr "Step 2/3 — Workers" + printf 'RAM per worker = ~240 MB × number of languages loaded\n' + printf 'WORKERS [default: 1]: ' + read -r WORKERS + WORKERS="${WORKERS:-1}" + case "$WORKERS" in + ''|*[!0-9]*|0) warn "Invalid — using 1"; WORKERS=1 ;; + esac + ok "Workers: $WORKERS" + + hdr "Step 3/3 — HuggingFace Token" + printf 'Required to download voice models from HuggingFace.\n' + printf 'Get a free read-only token at: https://huggingface.co/settings/tokens\n\n' + printf 'HF_TOKEN (input hidden): ' + read -rs HF_TOKEN + printf '\n' + if [[ -z "$HF_TOKEN" ]]; then + warn "No token entered — model downloads may be rate-limited until HF_TOKEN is set." + else + ok "Token recorded" + fi + + cat > "$ENV_FILE" << 'EOF' +# Readium Speech Server — environment variables +# Generated by configure.sh — edit manually or re-run configure.sh + +# ── Server ── +APP_ENV=development +LOG_LEVEL=INFO +HOST=0.0.0.0 +PORT=8000 +API_V1_PREFIX=/v1 +WORKERS=1 + +# ── Auth (off by default) ── +API_KEY_ENABLED=false +API_KEY= + +# ── Concurrency ── +MAX_CONCURRENT_SYNTHESES=2 + +# ── Languages (comma-separated BCP-47 prefixes; PocketTTS: en,fr,it,de,es,pt) ── +# Each language downloads ~240 MB on first start into the weights Docker volume. +LANGUAGES=en +HF_TOKEN= + +# ── Providers ── +ENABLED_PROVIDERS=pocket +DEFAULT_PROVIDER=pocket + +# ── PocketTTS ── +POCKET_DEFAULT_VOICE=alba + +# ── Audio / ffmpeg ── +FFMPEG_BIN=ffmpeg +MAX_TEXT_LENGTH=2000 +EOF + chmod 600 "$ENV_FILE" + set_env LANGUAGES "$LANGS" + set_env WORKERS "$WORKERS" + set_env HF_TOKEN "$HF_TOKEN" + + ok "Written → .env" + _print_next_steps "$LANGS" +} + +do_add_language() { + _require_env + local current + current=$(current_langs) + + hdr "Add Language" + show_lang_menu "$current" + printf 'Number to add: ' + read -r NUM + code=$(num_to_lang "$NUM") + if [[ -z "$code" ]]; then + err "Invalid selection." + return + fi + if echo "$current" | grep -qE "(^|,)${code}(,|$)"; then + warn "$code ($(lang_name "$code")) already in LANGUAGES=$current" + return + fi + local new + new=$(lang_list_to_set "${current},${code}") + set_env LANGUAGES "$new" + ok "LANGUAGES updated → $new" + printf '\n%sRestart the server to load the new language:%s\n' "$B" "$R" + printf ' make stop && make start\n\n' + printf '%sNote:%s first start downloads ~240 MB for %s%s%s. Cached after that.\n\n' \ + "$Y" "$R" "$B" "$code" "$R" +} + +do_remove_language() { + _require_env + local current + current=$(current_langs) + IFS=',' read -ra parts <<< "$current" + + if [[ ${#parts[@]} -eq 1 ]]; then + err "Only one language configured (${current}). Cannot remove — server needs at least one." + return + fi + + hdr "Remove Language" + show_lang_menu "$current" + printf 'Number to remove: ' + read -r NUM + code=$(num_to_lang "$NUM") + if [[ -z "$code" ]]; then + err "Invalid selection."; return + fi + if ! echo "$current" | grep -qE "(^|,)${code}(,|$)"; then + warn "$code is not in LANGUAGES=$current" + return + fi + + local new="" p2 + for p2 in "${parts[@]}"; do + [[ "$p2" != "$code" ]] && new="${new:+${new},}${p2}" + done + set_env LANGUAGES "$new" + ok "LANGUAGES updated → $new" + printf '\n%sNote:%s model files for %s remain in the Docker volume (disk space not freed).\n' \ + "$Y" "$R" "$code" + printf 'To reclaim disk: %smake stop && docker volume rm speech-server_weights_cache && make start%s\n' \ + "$C" "$R" + printf '(Re-downloads active languages: %s)\n\n' "$new" + printf 'Restart to apply: %smake stop && make start%s\n\n' "$C" "$R" +} + +do_change_workers() { + _require_env + local current + current=$(get_env WORKERS) + current="${current:-1}" + + hdr "Change Workers" + local langs + langs=$(current_langs) + local lang_count + lang_count=$(echo "$langs" | tr ',' '\n' | wc -l | tr -d ' ') + printf 'Current: %s%s%s workers | RAM per worker: ~%d MB (%d language(s) × 240 MB)\n\n' \ + "$C" "$current" "$R" $((lang_count * 240)) "$lang_count" + printf 'New WORKERS [current: %s]: ' "$current" + read -r WORKERS + WORKERS="${WORKERS:-$current}" + case "$WORKERS" in + ''|*[!0-9]*|0) warn "Invalid — keeping $current"; return ;; + esac + set_env WORKERS "$WORKERS" + ok "WORKERS updated → $WORKERS" + local total_ram=$(( lang_count * 240 * WORKERS )) + printf 'Estimated RAM: ~%d MB (%d workers × %d languages × 240 MB)\n\n' \ + "$total_ram" "$WORKERS" "$lang_count" + printf 'Restart to apply: %smake stop && make start%s\n\n' "$C" "$R" +} + +do_update_token() { + _require_env + + hdr "Update HF_TOKEN" + printf 'Get a free read-only token at: https://huggingface.co/settings/tokens\n\n' + printf 'New HF_TOKEN (input hidden): ' + read -rs HF_TOKEN + printf '\n' + [[ -z "$HF_TOKEN" ]] && { warn "Empty token — no change made."; return; } + set_env HF_TOKEN "$HF_TOKEN" + ok "HF_TOKEN updated in .env" + printf 'Takes effect on next container start.\n\n' +} + +do_show_config() { + _require_env + + hdr "Current Configuration" + local langs workers hf_token key_enabled api_key max_syn + langs=$(current_langs) + workers=$(get_env WORKERS); workers="${workers:-1}" + hf_token=$(get_env HF_TOKEN) + key_enabled=$(get_env API_KEY_ENABLED); key_enabled="${key_enabled:-false}" + max_syn=$(get_env MAX_CONCURRENT_SYNTHESES); max_syn="${max_syn:-2}" + + printf ' %-30s %s%s%s\n' "Languages:" "$C" "$langs" "$R" + + local lang_count + lang_count=$(echo "$langs" | tr ',' '\n' | wc -l | tr -d ' ') + local total_ram=$(( lang_count * 240 * workers )) + + printf ' %-30s %s%s%s (~%d MB total RAM)\n' \ + "Workers:" "$C" "$workers" "$R" "$total_ram" + printf ' %-30s %s%s%s\n' "Max concurrent syntheses:" "$C" "$max_syn" "$R" + + if [[ -n "$hf_token" ]]; then + local masked="${hf_token:0:4}****${hf_token: -4}" + printf ' %-30s %s%s%s\n' "HF_TOKEN:" "$C" "$masked" "$R" + else + printf ' %-30s %s(not set)%s\n' "HF_TOKEN:" "$Y" "$R" + fi + + printf ' %-30s %s%s%s\n' "API key auth:" "$C" "$key_enabled" "$R" + printf '\n Config file: %s\n\n' "$ENV_FILE" +} + +do_reset() { + hdr "Reset" + warn "This will delete .env and stop the server." + printf '\n 1) Delete .env only (keep downloaded models)\n' + printf ' 2) Delete .env AND purge all downloaded models (frees disk)\n' + printf ' 3) Cancel\n\n' + printf 'Choice [default: 3]: ' + read -r CHOICE + + case "${CHOICE:-3}" in + 1) + [[ -f "$ENV_FILE" ]] && rm "$ENV_FILE" && ok ".env deleted" + printf 'Run %sconfigure.sh%s to set up again.\n\n' "$C" "$R" ;; + 2) + require_docker + [[ -f "$ENV_FILE" ]] && rm "$ENV_FILE" && ok ".env deleted" + local vol + vol=$(docker volume ls -q | grep -E 'weights_cache$' | head -1 || true) + if [[ -n "$vol" ]]; then + # stop containers using the volume first + docker compose -f "$REPO_ROOT/docker-compose.yml" down 2>/dev/null || true + docker volume rm "$vol" && ok "Volume '$vol' removed (models purged)" + else + warn "No weights_cache volume found — models already gone or never downloaded" + fi + printf 'Run %sconfigure.sh%s then %smake build && make start%s.\n\n' \ + "$C" "$R" "$C" "$R" ;; + *) + printf 'Cancelled.\n\n' ;; + esac +} + +# ── Helpers ─────────────────────────────────────────────────────────────────── +_require_env() { + if [[ ! -f "$ENV_FILE" ]]; then + err ".env not found. Run option 1 (first-time setup) first." + exit 1 + fi +} + +_print_next_steps() { + local langs="$1" + printf '\n%sNext steps:%s\n\n' "$B" "$R" + printf ' %smake build%s build the Docker image\n' "$C" "$R" + printf ' %smake dev-docker%s start (hot-reload, logs in terminal)\n' "$C" "$R" + printf ' %smake start%s start in background (production)\n' "$C" "$R" + printf ' %smake test-docker%s run fast test suite\n' "$C" "$R" + printf ' %smake logs%s tail container logs\n\n' "$C" "$R" + printf '%sNote:%s first startup downloads voice models for: %s%s%s\n' \ + "$Y" "$R" "$B" "$langs" "$R" + printf 'Subsequent starts are instant — models cached in Docker volume.\n\n' +} + +# ══════════════════════════════════════════════════════════════════════════════ +# Main menu +# ══════════════════════════════════════════════════════════════════════════════ + +main_menu() { + while true; do + printf '\n%sReadium Speech Server%s\n\n' "$B" "$R" + + if [[ -f "$ENV_FILE" ]]; then + local langs workers + langs=$(current_langs) + workers=$(get_env WORKERS); workers="${workers:-1}" + printf ' Current: languages=%s%s%s workers=%s%s%s\n\n' \ + "$C" "$langs" "$R" "$C" "$workers" "$R" + printf ' 1) Show full config\n' + printf ' 2) Add a language\n' + printf ' 3) Remove a language\n' + printf ' 4) Change workers\n' + printf ' 5) Update HF token\n' + printf ' 6) First-time setup (re-run / overwrite)\n' + printf ' 7) Reset\n' + printf ' q) Quit\n\n' + printf 'Choice: ' + read -r OPT + case "$OPT" in + 1) do_show_config ;; + 2) do_add_language ;; + 3) do_remove_language ;; + 4) do_change_workers ;; + 5) do_update_token ;; + 6) do_first_setup ;; + 7) do_reset ;; + q|Q) printf '\n'; exit 0 ;; + *) warn "Unknown option '$OPT'" ;; + esac + else + printf ' No .env found.\n\n' + printf ' 1) First-time setup\n' + printf ' q) Quit\n\n' + printf 'Choice: ' + read -r OPT + case "$OPT" in + 1) do_first_setup ;; + q|Q) printf '\n'; exit 0 ;; + *) warn "Unknown option '$OPT'" ;; + esac + fi + done +} + +main_menu diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index c3e4675..a1e6d27 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -7,10 +7,16 @@ if [[ "${API_KEY_ENABLED:-false}" == "true" && -z "${API_KEY:-}" ]]; then exit 1 fi -# Phase 2+: ensure model weights are present -# python /app/scripts/fetch_weights.py - -exec uvicorn app.main:app \ - --host "${HOST:-0.0.0.0}" \ - --port "${PORT:-8000}" \ - --workers "${WORKERS:-1}" +if [[ "${RELOAD:-false}" == "true" ]]; then + exec uvicorn app.main:app \ + --host "${HOST:-0.0.0.0}" \ + --port "${PORT:-8000}" \ + --reload \ + --timeout-graceful-shutdown 3 +else + exec uvicorn app.main:app \ + --host "${HOST:-0.0.0.0}" \ + --port "${PORT:-8000}" \ + --workers "${WORKERS:-1}" \ + --timeout-graceful-shutdown 5 +fi diff --git a/tests/conftest.py b/tests/conftest.py index 05e93f8..57def81 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,18 @@ from app.providers.fake import FakeProvider +@pytest.fixture(autouse=True) +def mock_ffmpeg(monkeypatch: pytest.MonkeyPatch) -> None: + import app.drivers.ffmpeg as ffmpeg_mod + + monkeypatch.setattr(ffmpeg_mod, "is_available", lambda *_: True) + + async def _fake_encode(pcm: bytes, sample_rate: int, format: str, **kwargs: object) -> bytes: + return b"\x00" * 200 + + monkeypatch.setattr(ffmpeg_mod, "encode", _fake_encode) + + @pytest.fixture def app() -> FastAPI: return create_app() diff --git a/tests/contract/__init__.py b/tests/contract/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/contract/test_provider_contract.py b/tests/contract/test_provider_contract.py new file mode 100644 index 0000000..2b79f21 --- /dev/null +++ b/tests/contract/test_provider_contract.py @@ -0,0 +1,98 @@ +"""Provider Contract Suite — every TTSProvider must pass these. + +FakeProvider runs in the fast suite (no model download). +PocketTTS runs under `pytest -m integration` (requires real model). + +NOTE: test_speed_affects_duration is marked xfail for pocket — PocketTTS has +no native speed parameter. Speed via ffmpeg atempo is not yet implemented. +""" + +from __future__ import annotations + +import inspect + +import pytest + +from app.core.concurrency import init_semaphore +from app.providers.base import TTSProvider +from app.providers.fake import FakeProvider +from app.schemas.audio import SynthesisParams +from tests.helpers.audio import assert_valid_pcm, pcm_duration_seconds + +PROVIDERS = [ + pytest.param("fake", id="fake"), + pytest.param("pocket", id="pocket", marks=pytest.mark.integration), +] + + +@pytest.fixture +async def provider(request: pytest.FixtureRequest) -> TTSProvider: + init_semaphore(2) + name: str = request.param + if name == "fake": + return FakeProvider() + if name == "pocket": + from app.providers.pocket_tts import PocketTTSProvider + + p = PocketTTSProvider() + await p.load() + return p + raise ValueError(f"Unknown provider: {name}") + + +def _params( + voice_uri: str, + text: str = "Hello world, this is a test.", + speed: float = 1.0, +) -> SynthesisParams: + return SynthesisParams( + text=text, + ssml=False, + language="en-US", + voice_uri=voice_uri, + speed=speed, + pitch=None, + ) + + +@pytest.mark.contract +@pytest.mark.parametrize("provider", PROVIDERS, indirect=True) +class TestProviderContract: + async def test_list_voices_nonempty_and_shaped(self, provider: TTSProvider) -> None: + voices = await provider.list_voices() + assert len(voices) >= 1 + for v in voices: + assert v.voiceURI and v.name and v.language + assert v.provider == provider.id + assert v.engineVoiceId + assert v.sampleRate > 0 + + async def test_voiceuri_is_unique(self, provider: TTSProvider) -> None: + uris = [v.voiceURI for v in await provider.list_voices()] + assert len(uris) == len(set(uris)) + + async def test_synthesize_returns_valid_audio(self, provider: TTSProvider) -> None: + v = (await provider.list_voices())[0] + res = await provider.synthesize(_params(v.voiceURI)) + assert res.sample_rate > 0 + assert_valid_pcm(res) + + async def test_longer_text_yields_longer_audio(self, provider: TTSProvider) -> None: + v = (await provider.list_voices())[0] + short = await provider.synthesize(_params(v.voiceURI, "Hi.")) + long_ = await provider.synthesize(_params(v.voiceURI, "Hi. " * 20)) + assert pcm_duration_seconds(long_) > pcm_duration_seconds(short) + + async def test_speed_affects_duration(self, provider: TTSProvider) -> None: + if provider.id == "pocket": + pytest.xfail("PocketTTS has no native speed param; ffmpeg atempo not yet implemented.") + v = (await provider.list_voices())[0] + slow = await provider.synthesize(_params(v.voiceURI, speed=0.8)) + fast = await provider.synthesize(_params(v.voiceURI, speed=1.5)) + assert pcm_duration_seconds(fast) < pcm_duration_seconds(slow) + + async def test_synthesize_is_async_non_blocking(self, provider: TTSProvider) -> None: + assert inspect.iscoroutinefunction(provider.synthesize) + + async def test_health_returns_bool(self, provider: TTSProvider) -> None: + assert isinstance(await provider.health(), bool) diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/helpers/audio.py b/tests/helpers/audio.py new file mode 100644 index 0000000..384f9ed --- /dev/null +++ b/tests/helpers/audio.py @@ -0,0 +1,32 @@ +"""Structural audio assertion helpers — never byte-exact.""" + +from __future__ import annotations + +import struct + +from app.schemas.audio import AudioResult + + +def assert_valid_pcm(result: AudioResult) -> None: + """Non-empty PCM bytes; length is a whole number of 16-bit samples.""" + assert result.pcm is not None, "AudioResult.pcm is None" + assert len(result.pcm) > 0, "PCM is empty" + assert len(result.pcm) % 2 == 0, "PCM length is not a whole number of 16-bit samples" + assert result.sample_rate > 0, "sample_rate must be positive" + + +def pcm_duration_seconds(result: AudioResult) -> float: + """Convert PCM byte length to duration in seconds (16-bit mono assumed).""" + assert result.pcm is not None + samples = len(result.pcm) // 2 # 16-bit = 2 bytes per sample + return samples / result.sample_rate + + +def assert_valid_wav(data: bytes) -> None: + """RIFF/WAVE header present, non-zero data chunk.""" + assert len(data) >= 44, "WAV too short for header" + assert data[:4] == b"RIFF", "Missing RIFF marker" + assert data[8:12] == b"WAVE", "Missing WAVE marker" + assert data[12:16] == b"fmt ", "Missing fmt chunk" + data_size = struct.unpack_from(" 0, "WAV data chunk is empty" diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_pocket_tts.py b/tests/integration/test_pocket_tts.py new file mode 100644 index 0000000..35c2880 --- /dev/null +++ b/tests/integration/test_pocket_tts.py @@ -0,0 +1,82 @@ +"""Integration tests for PocketTTSProvider — requires real model download. + +Run with: pytest -m integration +Skipped by default (CI uses fast suite only). +""" + +import pytest + +from app.providers.pocket_tts import PocketTTSProvider +from app.schemas.audio import SynthesisParams +from tests.helpers.audio import assert_valid_pcm, pcm_duration_seconds + +pytestmark = pytest.mark.integration + + +@pytest.fixture +async def pocket() -> PocketTTSProvider: + p = PocketTTSProvider() + await p.load() + return p + + +async def test_health_ready(pocket: PocketTTSProvider) -> None: + assert await pocket.health() is True + + +async def test_list_voices_nonempty_and_shaped(pocket: PocketTTSProvider) -> None: + voices = await pocket.list_voices() + assert len(voices) >= 1 + for v in voices: + assert v.voiceURI.startswith("urn:readium:tts:pocket:") + assert v.name and v.language + assert v.provider == "pocket" + assert v.engineVoiceId + assert v.sampleRate > 0 + + +async def test_voiceuri_unique(pocket: PocketTTSProvider) -> None: + uris = [v.voiceURI for v in await pocket.list_voices()] + assert len(uris) == len(set(uris)) + + +async def test_synthesize_returns_valid_pcm(pocket: PocketTTSProvider) -> None: + v = (await pocket.list_voices())[0] + params = SynthesisParams( + text="Hello world, this is a test.", + ssml=False, + language="en-US", + engineVoiceId=v.engineVoiceId, + speed=1.0, + pitch=None, + ) + result = await pocket.synthesize(params) + assert_valid_pcm(result) + assert result.sample_rate == 24000 + + +async def test_longer_text_yields_more_audio(pocket: PocketTTSProvider) -> None: + v = (await pocket.list_voices())[0] + + def params(text: str) -> SynthesisParams: + return SynthesisParams( + text=text, + ssml=False, + language=None, + engineVoiceId=v.engineVoiceId, + speed=1.0, + pitch=None, + ) + + short = await pocket.synthesize(params("Hi.")) + long_ = await pocket.synthesize(params("Hi. " * 20)) + assert pcm_duration_seconds(long_) > pcm_duration_seconds(short) + + +@pytest.mark.skip( + reason=( + "PocketTTS has no native speed parameter — generate_audio() does not accept rate. " + "Speed via ffmpeg atempo post-processing is not yet implemented." + ) +) +async def test_speed_affects_duration(pocket: PocketTTSProvider) -> None: ... diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..5c38c8a --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,31 @@ +import pytest +from httpx import AsyncClient + +_VOICE = "urn:readium:tts:fake:en-US-standard" +_URL = "/v1/synthesize" + + +@pytest.mark.route +async def test_text_too_long_returns_413(client: AsyncClient) -> None: + resp = await client.post(_URL, json={"text": "x" * 2001, "voice": _VOICE}) + assert resp.status_code == 413 + assert resp.json()["error"]["code"] == "payload_too_large" + + +@pytest.mark.route +async def test_ssml_true_does_not_500(client: AsyncClient) -> None: + resp = await client.post( + _URL, + json={ + "text": "Hello world", + "voice": _VOICE, + "ssml": True, + }, + ) + assert resp.status_code == 200 + + +@pytest.mark.route +async def test_readyz_returns_200_when_ready(client: AsyncClient) -> None: + resp = await client.get("/readyz") + assert resp.status_code == 200 diff --git a/tests/test_formats.py b/tests/test_formats.py new file mode 100644 index 0000000..05016f7 --- /dev/null +++ b/tests/test_formats.py @@ -0,0 +1,48 @@ +import pytest +from httpx import AsyncClient + +_VOICE = "urn:readium:tts:fake:en-US-standard" +_SYNTH = "/v1/synthesize" + + +def _body(fmt: str) -> dict: # type: ignore[type-arg] + return {"text": "Hello", "voice": _VOICE, "output": {"format": fmt}} + + +@pytest.mark.route +async def test_wav_returns_riff(client: AsyncClient) -> None: + resp = await client.post(_SYNTH, json=_body("wav")) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "audio/wav" + assert resp.content[:4] == b"RIFF" + assert resp.content[8:12] == b"WAVE" + + +@pytest.mark.route +async def test_mp3_returns_audio(client: AsyncClient) -> None: + resp = await client.post(_SYNTH, json=_body("mp3")) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "audio/mpeg" + assert len(resp.content) > 100 + + +@pytest.mark.route +async def test_opus_returns_audio(client: AsyncClient) -> None: + resp = await client.post(_SYNTH, json=_body("opus")) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "audio/ogg" + assert len(resp.content) > 100 + + +@pytest.mark.route +async def test_default_format_is_mp3(client: AsyncClient) -> None: + resp = await client.post(_SYNTH, json={"text": "Hello", "voice": _VOICE}) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "audio/mpeg" + + +@pytest.mark.route +async def test_output_bitrate_accepted(client: AsyncClient) -> None: + body = {"text": "Hello", "voice": _VOICE, "output": {"format": "mp3", "bitrate": 64}} + resp = await client.post(_SYNTH, json=body) + assert resp.status_code == 200 diff --git a/tests/test_language_filter.py b/tests/test_language_filter.py new file mode 100644 index 0000000..a86fdb8 --- /dev/null +++ b/tests/test_language_filter.py @@ -0,0 +1,146 @@ +"""Fast unit tests for language filtering logic — no model download required.""" + +import json +from collections.abc import Generator +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import PropertyMock, patch + +import pytest + +from app.config.settings import Settings + +_POCKET_JSON = Path(__file__).parent.parent / "app" / "data" / "voices" / "pocket" / "voices.json" +_SUPPORTED = {"en", "fr", "it", "de", "es", "pt"} + + +@contextmanager +def language_config(*langs: str) -> Generator[None, None, None]: + """Temporarily override settings.language_list for a block.""" + with patch.object( + Settings, "language_list", new_callable=PropertyMock, return_value=list(langs) + ): # noqa: E501 + yield + + +# ── pocket.json data integrity ────────────────────────────────────────────── + + +def test_pocket_json_covers_all_supported_languages() -> None: + voices = json.loads(_POCKET_JSON.read_text()) + present = {v["language"].split("-")[0].lower() for v in voices} + assert present == _SUPPORTED + + +def test_pocket_json_english_voice_count() -> None: + voices = json.loads(_POCKET_JSON.read_text()) + en = [v for v in voices if v["language"].split("-")[0].lower() == "en"] + assert len(en) == 26 # all 26 voices under language "en" + + +def test_pocket_json_non_english_languages_each_have_voices() -> None: + voices = json.loads(_POCKET_JSON.read_text()) + by_lang: dict[str, int] = {} + for v in voices: + lang = v["language"].split("-")[0].lower() + by_lang[lang] = by_lang.get(lang, 0) + 1 + for lang in _SUPPORTED - {"en"}: + assert by_lang.get(lang, 0) >= 1, f"No voices for language: {lang}" + + +def test_pocket_json_all_voices_have_required_fields() -> None: + required = { + "source", + "label", + "name", + "originalName", + "voiceURI", + "language", + "provider", + "engineVoiceId", + "sampleRate", + "mimeTypes", + } + for v in json.loads(_POCKET_JSON.read_text()): + missing = required - v.keys() + assert not missing, f"{v.get('voiceURI')} missing: {missing}" + + +# ── active_languages() intersection logic ──────────────────────────────────── + + +def test_active_languages_single() -> None: + from app.providers.pocket_tts import PocketTTSProvider + + with language_config("en"): + assert PocketTTSProvider().active_languages() == {"en"} + + +def test_active_languages_multiple() -> None: + from app.providers.pocket_tts import PocketTTSProvider + + with language_config("en", "fr", "de"): + assert PocketTTSProvider().active_languages() == {"en", "fr", "de"} + + +def test_active_languages_unsupported_lang_excluded() -> None: + from app.providers.pocket_tts import PocketTTSProvider + + with language_config("en", "zh"): + assert PocketTTSProvider().active_languages() == {"en"} # zh not supported + + +def test_active_languages_all_unsupported_returns_empty() -> None: + from app.providers.pocket_tts import PocketTTSProvider + + with language_config("zh", "ar"): + assert PocketTTSProvider().active_languages() == frozenset() + + +# ── list_voices() filtering (no model load — injects Voice objects directly) ─ + + +def _loaded_provider(*langs: str): # type: ignore[no-untyped-def] + """Return a PocketTTSProvider with all pocket.json voices pre-injected.""" + from app.providers.pocket_tts import PocketTTSProvider + from app.schemas.voice import Voice + + provider = PocketTTSProvider() + provider._voices = [Voice(**v) for v in json.loads(_POCKET_JSON.read_text())] # noqa: SLF001 + return provider, langs + + +@pytest.mark.asyncio +async def test_list_voices_filters_to_single_language() -> None: + provider, langs = _loaded_provider("fr") + with language_config(*langs): + result = await provider.list_voices() + assert {v.language.split("-")[0].lower() for v in result} == {"fr"} + assert len(result) == 26 # all 26 voices available in French + + +@pytest.mark.asyncio +async def test_list_voices_multiple_languages() -> None: + provider, langs = _loaded_provider("en", "fr") + with language_config(*langs): + result = await provider.list_voices() + langs_in_result = {v.language.split("-")[0].lower() for v in result} + assert langs_in_result == {"en", "fr"} + assert len(result) == 52 # 26 en + 26 fr + + +@pytest.mark.asyncio +async def test_list_voices_all_languages_returns_all() -> None: + provider, langs = _loaded_provider(*_SUPPORTED) + with language_config(*langs): + result = await provider.list_voices() + assert len(result) == 156 # 26 voices × 6 languages + + +@pytest.mark.asyncio +async def test_list_voices_unsupported_config_returns_empty() -> None: + """zh not in supported_languages — must not leak all voices.""" + provider, langs = _loaded_provider("zh") + with language_config(*langs): + result = await provider.list_voices() + assert list(result) == [] diff --git a/tests/test_synthesize.py b/tests/test_synthesize.py index 7b725a6..b9b915e 100644 --- a/tests/test_synthesize.py +++ b/tests/test_synthesize.py @@ -4,7 +4,9 @@ from httpx import AsyncClient _VOICE = "urn:readium:tts:fake:en-US-standard" +_VOICE_FR = "urn:readium:tts:fake:fr-FR-standard" _URL = "/v1/synthesize" +_WAV = {"output": {"format": "wav"}} def _is_valid_wav(data: bytes) -> bool: @@ -15,7 +17,7 @@ def _is_valid_wav(data: bytes) -> bool: @pytest.mark.route async def test_synthesize_returns_wav(client: AsyncClient) -> None: - resp = await client.post(_URL, json={"text": "Hello", "voice": _VOICE, "format": "wav"}) + resp = await client.post(_URL, json={"text": "Hello", "voice": _VOICE, **_WAV}) assert resp.status_code == 200 assert resp.headers["content-type"] == "audio/wav" assert _is_valid_wav(resp.content) @@ -23,46 +25,60 @@ async def test_synthesize_returns_wav(client: AsyncClient) -> None: @pytest.mark.route async def test_synthesize_wav_is_nonempty_audio(client: AsyncClient) -> None: - resp = await client.post(_URL, json={"text": "Hello", "voice": _VOICE, "format": "wav"}) - data = resp.content - # parse data chunk size from WAV header offset 40 - data_size = struct.unpack_from(" 0 @pytest.mark.route async def test_synthesize_unknown_voice_returns_404(client: AsyncClient) -> None: - resp = await client.post(_URL, json={"text": "Hello", "voice": "urn:unknown", "format": "wav"}) + resp = await client.post(_URL, json={"text": "Hello", "voice": "urn:unknown", **_WAV}) assert resp.status_code == 404 - body = resp.json() - assert body["error"]["code"] == "voice_not_found" + assert resp.json()["error"]["code"] == "voice_not_found" @pytest.mark.route async def test_synthesize_empty_text_returns_400(client: AsyncClient) -> None: - resp = await client.post(_URL, json={"text": " ", "voice": _VOICE, "format": "wav"}) + resp = await client.post(_URL, json={"text": " ", "voice": _VOICE, **_WAV}) assert resp.status_code == 400 assert resp.json()["error"]["code"] == "validation_failed" @pytest.mark.route -async def test_synthesize_unsupported_format_returns_415(client: AsyncClient) -> None: - resp = await client.post(_URL, json={"text": "Hello", "voice": _VOICE, "format": "mp3"}) - assert resp.status_code == 415 - assert resp.json()["error"]["code"] == "unsupported_format" +async def test_synthesize_invalid_format_returns_422(client: AsyncClient) -> None: + body = {"text": "Hello", "voice": _VOICE, "output": {"format": "flac"}} + resp = await client.post(_URL, json=body) + assert resp.status_code == 422 @pytest.mark.route async def test_synthesize_schema_error_returns_422(client: AsyncClient) -> None: - # missing required 'voice' field - resp = await client.post(_URL, json={"text": "Hello", "format": "wav"}) + resp = await client.post(_URL, json={"text": "Hello", "output": {"format": "wav"}}) assert resp.status_code == 422 @pytest.mark.route async def test_synthesize_content_disposition(client: AsyncClient) -> None: - resp = await client.post(_URL, json={"text": "Hello", "voice": _VOICE, "format": "wav"}) - assert "attachment" in resp.headers.get("content-disposition", "") + resp = await client.post(_URL, json={"text": "Hello", "voice": _VOICE, **_WAV}) + assert "inline" in resp.headers.get("content-disposition", "") + + +@pytest.mark.route +async def test_synthesize_boundary_returns_json(client: AsyncClient) -> None: + resp = await client.post( + _URL, + json={"text": "Ceci est un test.", "voice": _VOICE_FR, "boundary": True}, + ) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/json" + body = resp.json() + assert "audio" in body + assert "format" in body + assert "boundaries" in body + assert body["boundaries"] is None # FakeProvider.supports_boundaries = False + import base64 + + assert len(base64.b64decode(body["audio"])) > 0 @pytest.mark.route @@ -72,11 +88,10 @@ async def test_synthesize_with_prosody_context(client: AsyncClient) -> None: json={ "text": "She opened the door.", "voice": _VOICE, - "format": "wav", - "speed": 0.9, "prev_utterance": "It was dark.", "next_utterance": "The room was cold.", "publication_id": "urn:isbn:9780000000000", + "output": {"format": "wav", "speed": 0.9}, }, ) assert resp.status_code == 200 diff --git a/tests/test_voices.py b/tests/test_voices.py index bee1f13..359abb2 100644 --- a/tests/test_voices.py +++ b/tests/test_voices.py @@ -18,9 +18,11 @@ async def test_list_voices_shape(client: AsyncClient) -> None: resp = await client.get("/v1/voices") voice = resp.json()[0] required = ( + "source", "label", - "voiceURI", "name", + "originalName", + "voiceURI", "language", "provider", "engineVoiceId", diff --git a/uv.lock b/uv.lock index 7554f9b..1e60e47 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,8 @@ revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version < '3.15'", + "python_full_version < '3.15' and sys_platform != 'darwin'", + "python_full_version < '3.15' and sys_platform == 'darwin'", ] [[package]] @@ -77,6 +78,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -86,6 +96,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "click" version = "8.4.1" @@ -107,6 +190,15 @@ 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 = "einops" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/77/850bef8d72ffb9219f0b1aac23fbc1bf7d038ee6ea666f331fa273031aa2/einops-0.8.2.tar.gz", hash = "sha256:609da665570e5e265e27283aab09e7f279ade90c4f01bcfca111f3d3e13f2827", size = 56261, upload-time = "2026-01-26T04:13:17.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/09/f8d8f8f31e4483c10a906437b4ce31bdf3d6d417b73fe33f1a8b59e34228/einops-0.8.2-py3-none-any.whl", hash = "sha256:54058201ac7087911181bfec4af6091bb59380360f069276601256a76af08193", size = 65638, upload-time = "2026-01-26T04:13:18.546Z" }, +] + [[package]] name = "fastapi" version = "0.138.0" @@ -123,6 +215,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/ff/8496d9847a5fedae775eb49460722d3efaa80487854273e9647ae876218c/fastapi-0.138.0-py3-none-any.whl", hash = "sha256:b6f54fd1bd72c80b0f899f172c61a600f6f7af9b43d4d772a018f35624048cb0", size = 126779, upload-time = "2026-06-20T01:18:03.483Z" }, ] +[[package]] +name = "filelock" +version = "3.29.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -132,6 +242,38 @@ 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.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/2d/57fd21d84d93efb4bd0b962383790e19dd1bc053501b4264c97903b4e83e/hf_xet-1.5.1.tar.gz", hash = "sha256:51ef4500dab3764b41135ee1381a4b62ce56fc54d4c92b719b59e597d6df5bf6", size = 876636, upload-time = "2026-06-08T23:02:53.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/ee/dd9ba7beae1005e54131b7d45263cc74c8a066d47d354e6d58ae9445a388/hf_xet-1.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:dbf48c0d02cf0b2e568944330c60d9120c272dabe013bd892d48e25bc6797577", size = 4069485, upload-time = "2026-06-08T23:02:13.193Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/9cae6cfeb4e03070874e73e5c97c66eb90369d3206b6a2b1ef5f96520888/hf_xet-1.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78e4e5192ad2b674c2e1160b651cb9134db974f8ae1835bdfbfb0166b894a43", size = 3838493, upload-time = "2026-06-08T23:02:15.282Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b4/d5c01e0eb6d9f2ca2dacd84d0d1b71e6cfbb2ef3208c968528e010e9b3d7/hf_xet-1.5.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6f7a04a8ad962422e225bc49fbbac99dc1806764b1f3e54dbd154bffa7593947", size = 4505658, upload-time = "2026-06-08T23:02:17.196Z" }, + { url = "https://files.pythonhosted.org/packages/76/c5/29a7598c0c6383c523dc22186d577f4e04267a626cd95ae60f67c00bfe66/hf_xet-1.5.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d48199c2bf4f8df0adc55d31d1368b6ec0e4d4f45bc86b08038089c23db0bed8", size = 4292822, upload-time = "2026-06-08T23:02:18.608Z" }, + { url = "https://files.pythonhosted.org/packages/04/9a/dceaf6ca69390126b86ea825fb354b93d01163199070b7bd849225de9468/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:97f212a88d14bbf573619a74b7fecb238de77d08fc702e54dec6f78276ca3283", size = 4491255, upload-time = "2026-06-08T23:02:20.124Z" }, + { url = "https://files.pythonhosted.org/packages/48/a7/e5a7afaacf6c1791fdbeeac42951fb81c3d2bc482992b115dedcc86d963e/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f61e3665892a6c8c5e765395838b8ddf36185da835253d4bc4509a81e49fb342", size = 4711062, upload-time = "2026-06-08T23:02:21.863Z" }, + { url = "https://files.pythonhosted.org/packages/53/49/2802f8433c9742ce281bddc1e65c02c32268ca3098d66828b05e12e45ee2/hf_xet-1.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f4ad3ebd4c32dd2b27099d69dc7b2df821e30767e46fb6ee6a0713778243b8ff", size = 4017205, upload-time = "2026-06-08T23:02:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5a/50c71195b9fb883659f596e7252faf4c18c58e753a9013bdbf9bac5d2250/hf_xet-1.5.1-cp313-cp313t-win_arm64.whl", hash = "sha256:8298485c1e36e7e67cbd01eeb1376619b7af43d4f1ec245caae306f890a8a32d", size = 3845426, upload-time = "2026-06-08T23:02:25.124Z" }, + { url = "https://files.pythonhosted.org/packages/05/24/5e0c28f80371c17d49fed004597d9d132cb75c1f6f53db2cb95f459d2312/hf_xet-1.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3474760d10e3bb6f92ff3f024fcb00c0b3e4001e9b035c7483e49a5dd17aa70f", size = 4069676, upload-time = "2026-06-08T23:02:26.759Z" }, + { url = "https://files.pythonhosted.org/packages/d2/17/261ba565b6a4d960fb478f61fdf919c0be5824645aaf1c319eca660c1611/hf_xet-1.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6762d89b9e3267dfd502b29b2a327b4525f33b17e7b509a78d94e2151a30ce30", size = 3838509, upload-time = "2026-06-08T23:02:28.573Z" }, + { url = "https://files.pythonhosted.org/packages/4e/44/7ffdc2e184b0d41fc0f683ba3936ef669ab63cf242cf36ef50e57d683668/hf_xet-1.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf67e6ed10260cef62e852789dc91ebb03f382d5bdc4b1dbeb64763ea275e7d6", size = 4505881, upload-time = "2026-06-08T23:02:30.257Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/788060d5aa4d5e671f1a31bf69624c314eb2d8babab3aa562f9e5d53444e/hf_xet-1.5.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c6b6cd08ca095058780b50b8ce4d6cbf6787bcf27841705d58a9d32246e3e47a", size = 4292995, upload-time = "2026-06-08T23:02:31.993Z" }, + { url = "https://files.pythonhosted.org/packages/22/93/c5540cbd6b55529b7dc42f6734e88cebee21aefbea34128b66229df56c57/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e1af0de8ca6f190d4294a28b88023db64a1e2d1d719cab044baf75bec569e7a9", size = 4491570, upload-time = "2026-06-08T23:02:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/03/f3/9d8ceab30f44f36c1679b1b8683054c71a0dadc787dbf07421891742d3ca/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4f561cbbb92f80960772059864b7fb07eae879adde1b2e781ec6f86f6ac26c59", size = 4711565, upload-time = "2026-06-08T23:02:35.454Z" }, + { url = "https://files.pythonhosted.org/packages/cd/54/27ed9a5e2cc583b4df82f75a03a4df8dbf55f5a9fa1f47f1fadfb20dbeac/hf_xet-1.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:e7dbb40617410f432182d918e37c12303fe6700fd6aa6c5964e30a535a4461d6", size = 4017343, upload-time = "2026-06-08T23:02:37.14Z" }, + { url = "https://files.pythonhosted.org/packages/ae/12/ecb2fc8d45e767580e3a37faa97cb895608b614965567efb4f18cff67e27/hf_xet-1.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6071d5ccb4d8d2cbd5fea5cc798da4f0ba3f44e25369591c4e89a4987050e61d", size = 3845716, upload-time = "2026-06-08T23:02:39.073Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d8/5e54cf37434759d1f4f2ba9b66077ff9d4c4e1f37b6bd7975da5c40d94ab/hf_xet-1.5.1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6abd35c3221eff63836618ddfb954dcf84798603f71d8e33e3ed7b04acfdbe6e", size = 4077794, upload-time = "2026-06-08T23:02:40.656Z" }, + { url = "https://files.pythonhosted.org/packages/35/94/4b2ecfbad8f8b04701a23aefb62f540b9137d058b7e1dbef16a32676f0e9/hf_xet-1.5.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:94e761bbd266bf4c03cee73753916062665ce8365aa40ed321f45afcb934b41e", size = 3845354, upload-time = "2026-06-08T23:02:42.702Z" }, + { url = "https://files.pythonhosted.org/packages/de/cc/f99f4bc7295023d7bd9ebbfd51f75cc530ca262c1227666268b8208f4b77/hf_xet-1.5.1-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:892e3a3a3aecc12aded8b93cf4f9cd059282c7de0732f7d55026f3abdf474350", size = 4514864, upload-time = "2026-06-08T23:02:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6e/21f7e5a2381278bd3b7b7a5a4d90038518bb6308a0c1daf5d9f8268bb178/hf_xet-1.5.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a93df2039190502835b1db8cd7e178b0b7b889fe9ab51299d5ced26e0dd879a4", size = 4303784, upload-time = "2026-06-08T23:02:46.203Z" }, + { url = "https://files.pythonhosted.org/packages/35/0e/f992bb6927ac1cb30ef74e62268f551f338bc32b2191f7c96a44c6f7283e/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0c97106032ef70467b4f6bc2d0ccc266d7613ee076afc56516c502f87ce1c4a6", size = 4500703, upload-time = "2026-06-08T23:02:47.628Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d1/90a498d05447980b977b1669246eeeeae4cfb0ea3e7a286eaba627f91bf9/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6208adb15d192b90e4c2ad2a27ed864359b2cb0f2494eb6d7c7f3699ac02e2bf", size = 4719498, upload-time = "2026-06-08T23:02:49.268Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b6/20f99cfe97cc663a711f7b33cc21d4793e51968e9a26125b4afcd77315ba/hf_xet-1.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:f7b3002f95d1c13e24bcb4537baa8f0eb3838957067c91bb4959bc004a6435f5", size = 4026419, upload-time = "2026-06-08T23:02:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/fa/77453694888f03e5a8c8852d1514a0894d8e81c622d39edbaf308ea0dcf4/hf_xet-1.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:93d090b57b211133f6c0dab0205ef5cb6d89162979ba75a74845045cc3063b8e", size = 3855178, upload-time = "2026-06-08T23:02:52.452Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -196,6 +338,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "huggingface-hub" +version = "1.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { 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/8f/77/ce3331f40cb2d021fe9b24c46c41e72faf74493621138e5eddac12bf5e1c/huggingface_hub-1.21.0.tar.gz", hash = "sha256:a44f222cd8f2f7c2eade30b5e7a04cac984a3235fa61ea87a0a5a31db77d561f", size = 861572, upload-time = "2026-06-25T13:09:26.356Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/85/b505a99a133d9f99d21af182af416e9baef70bdeef019983479651e494c2/huggingface_hub-1.21.0-py3-none-any.whl", hash = "sha256:eadaa3678c512c82aea69e8675d90a184861e68de32f1105668628b4dce0e7cd", size = 721078, upload-time = "2026-06-25T13:09:24.402Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -214,6 +377,18 @@ 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 = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "librt" version = "0.11.0" @@ -274,6 +449,99 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] +[[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 = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[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 = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + [[package]] name = "mypy" version = "2.1.0" @@ -327,6 +595,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/0a/11486d02add7b1384dff7374d124b1cfbb0ee864dcc9f6a2c0380638cf84/numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561", size = 16789987, upload-time = "2026-06-21T20:56:16.657Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/285f48640a181947b4587a3766d21ec1eaa7fea833d4b49957e09da467a2/numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6", size = 11760322, upload-time = "2026-06-21T20:56:19.813Z" }, + { url = "https://files.pythonhosted.org/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be", size = 5319605, upload-time = "2026-06-21T20:56:22.173Z" }, + { url = "https://files.pythonhosted.org/packages/b9/83/03fc7300c7c6b6c84c487b1dc80d322817b95fbd1f4dd57a85e23b7198de/numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2", size = 6653628, upload-time = "2026-06-21T20:56:23.914Z" }, + { url = "https://files.pythonhosted.org/packages/82/49/2ec21730bc63ccfda829323f7040a8ed4715b3852ce658689cf74ee96a8c/numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8", size = 15153691, upload-time = "2026-06-21T20:56:25.631Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a", size = 16660066, upload-time = "2026-06-21T20:56:28.009Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2f/c354ec86d1f3f5c19649463b0d39652e160736e5b0a4cd18dff0576715c4/numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0", size = 16514638, upload-time = "2026-06-21T20:56:30.26Z" }, + { url = "https://files.pythonhosted.org/packages/06/34/43efdcb319988648580f93c11f1ae82cf7e2faa74925e98e454ae3aa95f8/numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54", size = 18419647, upload-time = "2026-06-21T20:56:32.41Z" }, + { url = "https://files.pythonhosted.org/packages/71/e2/f5d1676b1d7fb682eb5e9a1641e7ebd2414b3216c370661d1029778908b4/numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5", size = 6056688, upload-time = "2026-06-21T20:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2", size = 12419237, upload-time = "2026-06-21T20:56:36.24Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/2e0882f4044d1b1a1b63e875151fb2393389032022a8b7f5657a7996d3b2/numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca", size = 10339912, upload-time = "2026-06-21T20:56:38.733Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2", size = 16784890, upload-time = "2026-06-21T20:56:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c", size = 11754584, upload-time = "2026-06-21T20:56:43.094Z" }, + { url = "https://files.pythonhosted.org/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd", size = 5313904, upload-time = "2026-06-21T20:56:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b0/bcd672edad27ecca7da1f7bb0ce72cd1706a4f2d79ae94990afc97c13e1c/numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd", size = 6648504, upload-time = "2026-06-21T20:56:47.567Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/15cdfcbd30a1544a46c9e487a00df331c4672450216538705a9e51fa6710/numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e", size = 15150086, upload-time = "2026-06-21T20:56:49.352Z" }, + { url = "https://files.pythonhosted.org/packages/32/4e/8d7656ccaab3e81e97258b8a9bc5f0c8502513a92fb4ceb0a2cbfebc17bf/numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9", size = 16647250, upload-time = "2026-06-21T20:56:51.542Z" }, + { url = "https://files.pythonhosted.org/packages/3c/81/97060281b602ed07f21b12f4ec409eac1f75a2f91fbc829ed8b2becf3ad4/numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab", size = 16512864, upload-time = "2026-06-21T20:56:55.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/ab/4496208146911f8d8ddb54f68a972aafa6c8d44babcb2ea03b0e5cc87c9d/numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4", size = 18408407, upload-time = "2026-06-21T20:56:57.75Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9f/a4df67c181e4ee8b467aa3332dc2db10fd5c515136831302f3ca48bc0a01/numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988", size = 6054431, upload-time = "2026-06-21T20:57:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748", size = 12414420, upload-time = "2026-06-21T20:57:01.815Z" }, + { url = "https://files.pythonhosted.org/packages/eb/4a/25c2906f541e9d9f4c5769764db732e6627be91a13f4724fa10634d77db4/numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60", size = 10339533, upload-time = "2026-06-21T20:57:03.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/ad/abc44aaceaf7b17ee1edde2bbb4458da591bc79574cffff50c4bb35f00d1/numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9", size = 16783807, upload-time = "2026-06-21T20:57:06.253Z" }, + { url = "https://files.pythonhosted.org/packages/5d/39/b72e168daf9c00fb20c9fc996d00437ccecdef3102387775d29d7a62576d/numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446", size = 11765215, upload-time = "2026-06-21T20:57:08.547Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c", size = 5324493, upload-time = "2026-06-21T20:57:11.012Z" }, + { url = "https://files.pythonhosted.org/packages/f6/8c/0d104deaa0401c93395a629ec902891618a2eff76d19229139cb5a887bfc/numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303", size = 6645211, upload-time = "2026-06-21T20:57:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d9/4a4a628c812750363786afc3d33492709a5cd64b215469c16b0f6c7bb811/numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22", size = 15166004, upload-time = "2026-06-21T20:57:14.717Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03", size = 16650797, upload-time = "2026-06-21T20:57:16.906Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a0/a0090e6329f4ca5992c07847bb579c5259a19953dc57255bb08793142ffb/numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a", size = 16524647, upload-time = "2026-06-21T20:57:19.165Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7d/6caf27734c42b65837e7461ed0dbbd6b6fc835060c9714ec59d673bb383a/numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21", size = 18411841, upload-time = "2026-06-21T20:57:21.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/dc/26edadbd812536769a82c2e9e002234e33feb5da43061d47a044f6d309b7/numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731", size = 6106361, upload-time = "2026-06-21T20:57:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73", size = 12551749, upload-time = "2026-06-21T20:57:25.945Z" }, + { url = "https://files.pythonhosted.org/packages/05/a7/6bc6384c080b86c7f6c85c5bc5b540b24f4f679cd144791d99574e90d462/numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e", size = 10617072, upload-time = "2026-06-21T20:57:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/86/6b/4a2b71d66ada5608ae02b63f150dfad520f6940721cb7f029ad270befc0e/numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b", size = 11881067, upload-time = "2026-06-21T20:57:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b2/d365eb40a20efb49d67e9feb90494ed8511282ee1f5fa16006675c65397d/numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927", size = 5440290, upload-time = "2026-06-21T20:57:32.193Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5e/e9c03188de5f9b767e46a8fe988bcfd3efad066a4a3fda8b9cb11a93f895/numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826", size = 6748371, upload-time = "2026-06-21T20:57:33.933Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1d/68c186a38a5027bae2c4ddd5ea681fdaf8b4d30fb7301def6d8ad270390f/numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd", size = 15214643, upload-time = "2026-06-21T20:57:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/8c/67/73f67b7c7e20635baae9c4c3ead4ae7326a005900297a6110971abd62eb5/numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6", size = 16690128, upload-time = "2026-06-21T20:57:38.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/05/d4c1fb0c46d02a27d6b2b8b319a78c90937acec8631c1641874670b31e6f/numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a", size = 16577902, upload-time = "2026-06-21T20:57:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1d/771c797d50fa26e4888989cccf1d50ee51f530d4e455ad2692dcb64fa711/numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9", size = 18452814, upload-time = "2026-06-21T20:57:42.875Z" }, + { url = "https://files.pythonhosted.org/packages/e8/46/52fc0d2a68d7643f0f149eeea5a5d8ea2a3507056ac8afa83c9212606e8b/numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7", size = 6253168, upload-time = "2026-06-21T20:57:45.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/6c8d1118b5f13b2881dc095d5b345de19c6638b8959c17409b6eff84c8aa/numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa", size = 12736286, upload-time = "2026-06-21T20:57:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -354,6 +682,33 @@ 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 = "pocket-tts" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "einops" }, + { name = "fastapi" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "pydantic" }, + { name = "python-multipart" }, + { name = "requests" }, + { name = "safetensors" }, + { name = "scipy" }, + { name = "sentencepiece" }, + { name = "torch", version = "2.12.1", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "torch", version = "2.12.1+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "typer" }, + { name = "typing-extensions" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/2c/7445f57163bb40e2b2fab4df70d18a4216c4965cdf74196344d95859fc07/pocket_tts-2.1.0.tar.gz", hash = "sha256:6f244f445413400f686506f5ccfb75048547caab7b455b927f4a854c551c60a8", size = 642108, upload-time = "2026-05-04T14:00:29.207Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/63/d16958d388efee3f0fc7287e1418ed652ddbc2b61ff4f581f0ad0abce625/pocket_tts-2.1.0-py3-none-any.whl", hash = "sha256:7b8f01d3e52aa7df84887b711994586bdc875e024a8b40a15f757feeeb29f752", size = 68096, upload-time = "2026-05-04T14:00:27.547Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -505,6 +860,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -551,6 +915,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[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 = "ruff" version = "0.15.19" @@ -576,6 +968,147 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/66/9a73695e31eaee04f35d8475998bf8ab354465f9c638936d76111603dcc5/ruff-0.15.19-py3-none-win_arm64.whl", hash = "sha256:6c6b607466e47349332eb1d9be52fb1467423fc07c217341af41cd0f3f0573be", size = 11376779, upload-time = "2026-06-24T01:10:34.465Z" }, ] +[[package]] +name = "safetensors" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" }, + { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + +[[package]] +name = "sentencepiece" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/15/2e7a025fc62d764b151ae6d0f2a92f8081755ebe8d4a64099accc6f77ba6/sentencepiece-0.2.1.tar.gz", hash = "sha256:8138cec27c2f2282f4a34d9a016e3374cd40e5c6e9cb335063db66a0a3b71fad", size = 3228515, upload-time = "2025-08-12T07:00:51.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/be/32ce495aa1d0e0c323dcb1ba87096037358edee539cac5baf8755a6bd396/sentencepiece-0.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57cae326c8727de58c85977b175af132a7138d84c764635d7e71bbee7e774133", size = 1943152, upload-time = "2025-08-12T06:59:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/88/7e/ff23008899a58678e98c6ff592bf4d368eee5a71af96d0df6b38a039dd4f/sentencepiece-0.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:56dd39a3c4d6493db3cdca7e8cc68c6b633f0d4195495cbadfcf5af8a22d05a6", size = 1325651, upload-time = "2025-08-12T06:59:41.536Z" }, + { url = "https://files.pythonhosted.org/packages/19/84/42eb3ce4796777a1b5d3699dfd4dca85113e68b637f194a6c8d786f16a04/sentencepiece-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9381351182ff9888cc80e41c632e7e274b106f450de33d67a9e8f6043da6f76", size = 1253645, upload-time = "2025-08-12T06:59:42.903Z" }, + { url = "https://files.pythonhosted.org/packages/89/fa/d3d5ebcba3cb9e6d3775a096251860c41a6bc53a1b9461151df83fe93255/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99f955df238021bf11f0fc37cdb54fd5e5b5f7fd30ecc3d93fb48b6815437167", size = 1316273, upload-time = "2025-08-12T06:59:44.476Z" }, + { url = "https://files.pythonhosted.org/packages/04/88/14f2f4a2b922d8b39be45bf63d79e6cd3a9b2f248b2fcb98a69b12af12f5/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cdfecef430d985f1c2bcbfff3defd1d95dae876fbd0173376012d2d7d24044b", size = 1387881, upload-time = "2025-08-12T06:59:46.09Z" }, + { url = "https://files.pythonhosted.org/packages/fd/b8/903e5ccb77b4ef140605d5d71b4f9e0ad95d456d6184688073ed11712809/sentencepiece-0.2.1-cp312-cp312-win32.whl", hash = "sha256:a483fd29a34c3e34c39ac5556b0a90942bec253d260235729e50976f5dba1068", size = 999540, upload-time = "2025-08-12T06:59:48.023Z" }, + { url = "https://files.pythonhosted.org/packages/2d/81/92df5673c067148c2545b1bfe49adfd775bcc3a169a047f5a0e6575ddaca/sentencepiece-0.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:4cdc7c36234fda305e85c32949c5211faaf8dd886096c7cea289ddc12a2d02de", size = 1054671, upload-time = "2025-08-12T06:59:49.895Z" }, + { url = "https://files.pythonhosted.org/packages/fe/02/c5e3bc518655d714622bec87d83db9cdba1cd0619a4a04e2109751c4f47f/sentencepiece-0.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:daeb5e9e9fcad012324807856113708614d534f596d5008638eb9b40112cd9e4", size = 1033923, upload-time = "2025-08-12T06:59:51.952Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4a/85fbe1706d4d04a7e826b53f327c4b80f849cf1c7b7c5e31a20a97d8f28b/sentencepiece-0.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dcd8161eee7b41aae57ded06272905dbd680a0a04b91edd0f64790c796b2f706", size = 1943150, upload-time = "2025-08-12T06:59:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/c2/83/4cfb393e287509fc2155480b9d184706ef8d9fa8cbf5505d02a5792bf220/sentencepiece-0.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c6c8f42949f419ff8c7e9960dbadcfbc982d7b5efc2f6748210d3dd53a7de062", size = 1325651, upload-time = "2025-08-12T06:59:55.073Z" }, + { url = "https://files.pythonhosted.org/packages/8d/de/5a007fb53b1ab0aafc69d11a5a3dd72a289d5a3e78dcf2c3a3d9b14ffe93/sentencepiece-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:097f3394e99456e9e4efba1737c3749d7e23563dd1588ce71a3d007f25475fff", size = 1253641, upload-time = "2025-08-12T06:59:56.562Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d2/f552be5928105588f4f4d66ee37dd4c61460d8097e62d0e2e0eec41bc61d/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7b670879c370d350557edabadbad1f6561a9e6968126e6debca4029e5547820", size = 1316271, upload-time = "2025-08-12T06:59:58.109Z" }, + { url = "https://files.pythonhosted.org/packages/96/df/0cfe748ace5485be740fed9476dee7877f109da32ed0d280312c94ec259f/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7f0fd2f2693309e6628aeeb2e2faf6edd221134dfccac3308ca0de01f8dab47", size = 1387882, upload-time = "2025-08-12T07:00:00.701Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dd/f7774d42a881ced8e1739f393ab1e82ece39fc9abd4779e28050c2e975b5/sentencepiece-0.2.1-cp313-cp313-win32.whl", hash = "sha256:92b3816aa2339355fda2c8c4e021a5de92180b00aaccaf5e2808972e77a4b22f", size = 999541, upload-time = "2025-08-12T07:00:02.709Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e9/932b9eae6fd7019548321eee1ab8d5e3b3d1294df9d9a0c9ac517c7b636d/sentencepiece-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:10ed3dab2044c47f7a2e7b4969b0c430420cdd45735d78c8f853191fa0e3148b", size = 1054669, upload-time = "2025-08-12T07:00:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/c9/3a/76488a00ea7d6931689cda28726a1447d66bf1a4837943489314593d5596/sentencepiece-0.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac650534e2251083c5f75dde4ff28896ce7c8904133dc8fef42780f4d5588fcd", size = 1033922, upload-time = "2025-08-12T07:00:06.496Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b6/08fe2ce819e02ccb0296f4843e3f195764ce9829cbda61b7513f29b95718/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8dd4b477a7b069648d19363aad0cab9bad2f4e83b2d179be668efa672500dc94", size = 1946052, upload-time = "2025-08-12T07:00:08.136Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d9/1ea0e740591ff4c6fc2b6eb1d7510d02f3fb885093f19b2f3abd1363b402/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0c0f672da370cc490e4c59d89e12289778310a0e71d176c541e4834759e1ae07", size = 1327408, upload-time = "2025-08-12T07:00:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/99/7e/1fb26e8a21613f6200e1ab88824d5d203714162cf2883248b517deb500b7/sentencepiece-0.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ad8493bea8432dae8d6830365352350f3b4144415a1d09c4c8cb8d30cf3b6c3c", size = 1254857, upload-time = "2025-08-12T07:00:11.021Z" }, + { url = "https://files.pythonhosted.org/packages/bc/85/c72fd1f3c7a6010544d6ae07f8ddb38b5e2a7e33bd4318f87266c0bbafbf/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b81a24733726e3678d2db63619acc5a8dccd074f7aa7a54ecd5ca33ca6d2d596", size = 1315722, upload-time = "2025-08-12T07:00:12.989Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e8/661e5bd82a8aa641fd6c1020bd0e890ef73230a2b7215ddf9c8cd8e941c2/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a81799d0a68d618e89063fb423c3001a034c893069135ffe51fee439ae474d6", size = 1387452, upload-time = "2025-08-12T07:00:15.088Z" }, + { url = "https://files.pythonhosted.org/packages/99/5e/ae66c361023a470afcbc1fbb8da722c72ea678a2fcd9a18f1a12598c7501/sentencepiece-0.2.1-cp313-cp313t-win32.whl", hash = "sha256:89a3ea015517c42c0341d0d962f3e6aaf2cf10d71b1932d475c44ba48d00aa2b", size = 1002501, upload-time = "2025-08-12T07:00:16.966Z" }, + { url = "https://files.pythonhosted.org/packages/c1/03/d332828c4ff764e16c1b56c2c8f9a33488bbe796b53fb6b9c4205ddbf167/sentencepiece-0.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:33f068c9382dc2e7c228eedfd8163b52baa86bb92f50d0488bf2b7da7032e484", size = 1057555, upload-time = "2025-08-12T07:00:18.573Z" }, + { url = "https://files.pythonhosted.org/packages/88/14/5aee0bf0864df9bd82bd59e7711362908e4935e3f9cdc1f57246b5d5c9b9/sentencepiece-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:b3616ad246f360e52c85781e47682d31abfb6554c779e42b65333d4b5f44ecc0", size = 1036042, upload-time = "2025-08-12T07:00:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/24/9c/89eb8b2052f720a612478baf11c8227dcf1dc28cd4ea4c0c19506b5af2a2/sentencepiece-0.2.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5d0350b686c320068702116276cfb26c066dc7e65cfef173980b11bb4d606719", size = 1943147, upload-time = "2025-08-12T07:00:21.809Z" }, + { url = "https://files.pythonhosted.org/packages/82/0b/a1432bc87f97c2ace36386ca23e8bd3b91fb40581b5e6148d24b24186419/sentencepiece-0.2.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c7f54a31cde6fa5cb030370566f68152a742f433f8d2be458463d06c208aef33", size = 1325624, upload-time = "2025-08-12T07:00:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/ea/99/bbe054ebb5a5039457c590e0a4156ed073fb0fe9ce4f7523404dd5b37463/sentencepiece-0.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c83b85ab2d6576607f31df77ff86f28182be4a8de6d175d2c33ca609925f5da1", size = 1253670, upload-time = "2025-08-12T07:00:24.69Z" }, + { url = "https://files.pythonhosted.org/packages/19/ad/d5c7075f701bd97971d7c2ac2904f227566f51ef0838dfbdfdccb58cd212/sentencepiece-0.2.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1855f57db07b51fb51ed6c9c452f570624d2b169b36f0f79ef71a6e6c618cd8b", size = 1316247, upload-time = "2025-08-12T07:00:26.435Z" }, + { url = "https://files.pythonhosted.org/packages/fb/03/35fbe5f3d9a7435eebd0b473e09584bd3cc354ce118b960445b060d33781/sentencepiece-0.2.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01e6912125cb45d3792f530a4d38f8e21bf884d6b4d4ade1b2de5cf7a8d2a52b", size = 1387894, upload-time = "2025-08-12T07:00:28.339Z" }, + { url = "https://files.pythonhosted.org/packages/dc/aa/956ef729aafb6c8f9c443104c9636489093bb5c61d6b90fc27aa1a865574/sentencepiece-0.2.1-cp314-cp314-win32.whl", hash = "sha256:c415c9de1447e0a74ae3fdb2e52f967cb544113a3a5ce3a194df185cbc1f962f", size = 1096698, upload-time = "2025-08-12T07:00:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/fe400d8836952cc535c81a0ce47dc6875160e5fedb71d2d9ff0e9894c2a6/sentencepiece-0.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:881b2e44b14fc19feade3cbed314be37de639fc415375cefaa5bc81a4be137fd", size = 1155115, upload-time = "2025-08-12T07:00:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/89/047921cf70f36c7b6b6390876b2399b3633ab73b8d0cb857e5a964238941/sentencepiece-0.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:2005242a16d2dc3ac5fe18aa7667549134d37854823df4c4db244752453b78a8", size = 1133890, upload-time = "2025-08-12T07:00:34.763Z" }, + { url = "https://files.pythonhosted.org/packages/a1/11/5b414b9fae6255b5fb1e22e2ed3dc3a72d3a694e5703910e640ac78346bb/sentencepiece-0.2.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a19adcec27c524cb7069a1c741060add95f942d1cbf7ad0d104dffa0a7d28a2b", size = 1946081, upload-time = "2025-08-12T07:00:36.97Z" }, + { url = "https://files.pythonhosted.org/packages/77/eb/7a5682bb25824db8545f8e5662e7f3e32d72a508fdce086029d89695106b/sentencepiece-0.2.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e37e4b4c4a11662b5db521def4e44d4d30ae69a1743241412a93ae40fdcab4bb", size = 1327406, upload-time = "2025-08-12T07:00:38.669Z" }, + { url = "https://files.pythonhosted.org/packages/03/b0/811dae8fb9f2784e138785d481469788f2e0d0c109c5737372454415f55f/sentencepiece-0.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:477c81505db072b3ab627e7eab972ea1025331bd3a92bacbf798df2b75ea86ec", size = 1254846, upload-time = "2025-08-12T07:00:40.611Z" }, + { url = "https://files.pythonhosted.org/packages/ef/23/195b2e7ec85ebb6a547969f60b723c7aca5a75800ece6cc3f41da872d14e/sentencepiece-0.2.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:010f025a544ef770bb395091d57cb94deb9652d8972e0d09f71d85d5a0816c8c", size = 1315721, upload-time = "2025-08-12T07:00:42.914Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/553dbe4178b5f23eb28e59393dddd64186178b56b81d9b8d5c3ff1c28395/sentencepiece-0.2.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:733e59ff1794d26db706cd41fc2d7ca5f6c64a820709cb801dc0ea31780d64ab", size = 1387458, upload-time = "2025-08-12T07:00:44.56Z" }, + { url = "https://files.pythonhosted.org/packages/66/7c/08ff0012507297a4dd74a5420fdc0eb9e3e80f4e88cab1538d7f28db303d/sentencepiece-0.2.1-cp314-cp314t-win32.whl", hash = "sha256:d3233770f78e637dc8b1fda2cd7c3b99ec77e7505041934188a4e7fe751de3b0", size = 1099765, upload-time = "2025-08-12T07:00:46.058Z" }, + { url = "https://files.pythonhosted.org/packages/91/d5/2a69e1ce15881beb9ddfc7e3f998322f5cedcd5e4d244cb74dade9441663/sentencepiece-0.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e4366c97b68218fd30ea72d70c525e6e78a6c0a88650f57ac4c43c63b234a9d", size = 1157807, upload-time = "2025-08-12T07:00:47.673Z" }, + { url = "https://files.pythonhosted.org/packages/f3/16/54f611fcfc2d1c46cbe3ec4169780b2cfa7cf63708ef2b71611136db7513/sentencepiece-0.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:105e36e75cbac1292642045458e8da677b2342dcd33df503e640f0b457cb6751", size = 1136264, upload-time = "2025-08-12T07:00:49.485Z" }, +] + +[[package]] +name = "setuptools" +version = "81.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, +] + +[[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 = "speech-server" version = "0.1.0" @@ -584,8 +1117,11 @@ dependencies = [ { name = "anyio" }, { name = "fastapi" }, { name = "httpx" }, + { name = "pocket-tts" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "torch", version = "2.12.1", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "torch", version = "2.12.1+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -603,8 +1139,10 @@ requires-dist = [ { name = "anyio", specifier = ">=4.0.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", specifier = ">=0.27.0" }, + { name = "pocket-tts", specifier = ">=0.1.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, + { name = "torch", specifier = ">=2.5", index = "https://download.pytorch.org/whl/cpu" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, ] @@ -630,6 +1168,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "torch" +version = "2.12.1" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "python_full_version < '3.15' and sys_platform == 'darwin'", +] +dependencies = [ + { name = "filelock", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "fsspec", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "jinja2", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "networkx", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "setuptools", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "sympy", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:d2dd0f2c5f7ccbddaf34cade0deaf476808368f902b9cdb7f36a2ab42301bc0e", upload-time = "2026-06-17T15:44:03Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c75e93173c700bccd6bfcc4a9d19ce242ab6dacd1f1781483027a16239b9e650", upload-time = "2026-06-17T15:44:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e9b6f7d2dd66ea87a3ae620069d31335d594c06effb1a383bdd21cfe61e44ece", upload-time = "2026-06-17T15:44:16Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:2afbb2bdaa8a95040e733f05492ddf133c3967c9b7ce0abd218d704b6cab437d", upload-time = "2026-06-17T15:44:24Z" }, +] + +[[package]] +name = "torch" +version = "2.12.1+cpu" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15' and sys_platform != 'darwin'", +] +dependencies = [ + { name = "filelock", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "fsspec", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "jinja2", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "networkx", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "setuptools", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "sympy", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:900b253fc8c739bebffb63d7f75abff4cd53d79947265a00c16cb53b68ecdb91", upload-time = "2026-06-18T01:57:23Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d1620bc7bcf8087f3e48821b5db994a03e32ddb083d58c000b8e032f8a6e2d15", upload-time = "2026-06-18T01:57:29Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ae4bb28409f5370852bd71af221066236c38d647f780d9b0a7240c330a9c12df", upload-time = "2026-06-18T01:57:35Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:51c6c6e26eaa0d64ed439ebdc9ce3b8cc2d5cfcc7cdd4e72f17831d80886b7f4", upload-time = "2026-06-18T01:57:42Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:4ca206a35f2aeba7da944a69ef857103358e8d4bc6b06b49b9e554dcfa57777d", upload-time = "2026-06-18T01:57:47Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:8d47e0cfc59679d4e367646c3df4cf433c7d1595b31307e0e7b2391c58ca2160", upload-time = "2026-06-18T01:57:52Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:77d049968c7817456dbdebf0aadcac0813e302218b0e857a8723463331339d55", upload-time = "2026-06-18T01:57:59Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:06d13c354df07953ae0d150da22a071496cbbedd22d1b644961813c0f0fc3b23", upload-time = "2026-06-18T01:58:05Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1%2Bcpu-cp314-cp314-linux_s390x.whl", hash = "sha256:ef228ec70f5e73762c213f145c0fdb672e5770a6de760801c58b933b0d5b6957", upload-time = "2026-06-18T01:58:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:1b9ad70d0a300d6bd883fffc153a6c0f9bc64bf4190519aeeed0373807bffbcc", upload-time = "2026-06-18T01:58:15Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:618120d79360688595e61162147d37e1d69fc3f2e13d60584a1eb6a74c74735a", upload-time = "2026-06-18T01:58:22Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:6354069774ce2d9c26d6d8612fc252586428467b7e4ced7c8bbbfb961fafb783", upload-time = "2026-06-18T01:58:29Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1%2Bcpu-cp314-cp314t-linux_s390x.whl", hash = "sha256:0706eb7b53c4b1381b2acc418f6d89d6ed18a308677125ced7ca30519c57dc42", upload-time = "2026-06-18T01:58:33Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:21ed774c10a0fd24ab2cfaabfa98a9260fc28d518946c98c0151b6e447730250", upload-time = "2026-06-18T01:58:39Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:e3c3b5c275dd57cff1aa34a0ab8f84beab8976c63c8dc1d84f29430eac0590ea", upload-time = "2026-06-18T01:58:46Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.1%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:03094411b20e85a221125a332211d59c099dc556afb1423e04193fcf1b4c1cbd", upload-time = "2026-06-18T01:58:52Z" }, +] + +[[package]] +name = "tqdm" +version = "4.68.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, +] + +[[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" @@ -651,6 +1287,15 @@ 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" }, ] +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + [[package]] name = "uvicorn" version = "0.49.0"