From c16ff335e46443faede7769b36acbfeedf88a256 Mon Sep 17 00:00:00 2001 From: Joseph Cheek Date: Sat, 26 Jul 2025 17:06:37 -0600 Subject: [PATCH 01/45] Fix test_initial_state() test failure on apple silicon. --- api/tests/test_kokoro_v1.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/test_kokoro_v1.py b/api/tests/test_kokoro_v1.py index 29d83c5a..6891d8d3 100644 --- a/api/tests/test_kokoro_v1.py +++ b/api/tests/test_kokoro_v1.py @@ -19,7 +19,7 @@ def test_initial_state(kokoro_backend): assert kokoro_backend._model is None assert kokoro_backend._pipelines == {} # Now using dict of pipelines # Device should be set based on settings - assert kokoro_backend.device in ["cuda", "cpu"] + assert kokoro_backend.device in ["cuda", "cpu", "mps"] @patch("torch.cuda.is_available", return_value=True) From d29dcf76cfaae6fe559203b1ef7d45713dc28a21 Mon Sep 17 00:00:00 2001 From: "ryan.steed.usa" Date: Thu, 30 Oct 2025 00:41:39 -0600 Subject: [PATCH 02/45] fix: update PyTorch CUDA version from cu129 to cu126 - Updated GPU dependency from torch==2.8.0+cu129 to torch==2.8.0+cu126 in pyproject.toml - Changed PyTorch CUDA index URL from https://download.pytorch.org/whl/cu129 to https://download.pytorch.org/whl/cu126 - This change ensures compatibility with CUDA 12.6 runtime while maintaining the same PyTorch version (2.8.0) --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 13447b57..4913dbae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ ] [project.optional-dependencies] -gpu = ["torch==2.8.0+cu129"] +gpu = ["torch==2.8.0+cu126"] cpu = ["torch==2.8.0"] test = [ "pytest==8.3.5", @@ -70,7 +70,7 @@ explicit = true [[tool.uv.index]] name = "pytorch-cuda" -url = "https://download.pytorch.org/whl/cu129" +url = "https://download.pytorch.org/whl/cu126" explicit = true [build-system] From 88f04b1a9d14b0bc7aec60487f2c3c4ec2e96bd9 Mon Sep 17 00:00:00 2001 From: Alistair Keiller Date: Fri, 26 Dec 2025 12:09:05 -0800 Subject: [PATCH 03/45] add gpt-4o-mini-tts --- api/src/routers/openai_compatible.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api/src/routers/openai_compatible.py b/api/src/routers/openai_compatible.py index c3252217..97d2b59f 100644 --- a/api/src/routers/openai_compatible.py +++ b/api/src/routers/openai_compatible.py @@ -471,6 +471,12 @@ async def list_models(): "created": 1686935002, "owned_by": "kokoro", }, + { + "id": "gpt-4o-mini-tts", + "object": "model", + "created": 1686935002, + "owned_by": "kokoro", + }, ] return {"object": "list", "data": models} From af74c5fefe7d5e22a02906f9564939b8f06b6aee Mon Sep 17 00:00:00 2001 From: Alistair Keiller Date: Fri, 26 Dec 2025 12:54:07 -0800 Subject: [PATCH 04/45] add to json --- api/src/core/openai_mappings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/src/core/openai_mappings.json b/api/src/core/openai_mappings.json index 2821bd62..4439d12c 100644 --- a/api/src/core/openai_mappings.json +++ b/api/src/core/openai_mappings.json @@ -2,7 +2,8 @@ "models": { "tts-1": "kokoro-v1_0", "tts-1-hd": "kokoro-v1_0", - "kokoro": "kokoro-v1_0" + "kokoro": "kokoro-v1_0", + "gpt-4o-mini-tts": "kokoro-v1_0" }, "voices": { "alloy": "am_v0adam", From 397031218bdcac7b95331f085260841d2791df56 Mon Sep 17 00:00:00 2001 From: Will Date: Sat, 14 Feb 2026 15:48:03 -0700 Subject: [PATCH 05/45] =?UTF-8?q?fix:=20OGG/Opus=20truncation=20=E2=80=94?= =?UTF-8?q?=20close=20container=20before=20reading=20buffer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The finalize block in write_chunk() called output_buffer.getvalue() before container.close(). For OGG/Opus, the final page of audio data is only written to the buffer during close(), causing ~1-2 seconds of audio to be lost. Swap the order: close container first, then read buffer. Fixes: remsky/Kokoro-FastAPI#447 --- api/src/services/streaming_audio_writer.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/api/src/services/streaming_audio_writer.py b/api/src/services/streaming_audio_writer.py index de9c84e3..b1566f0b 100644 --- a/api/src/services/streaming_audio_writer.py +++ b/api/src/services/streaming_audio_writer.py @@ -80,13 +80,15 @@ def write_chunk( for packet in packets: self.container.mux(packet) - # Closing the container handles writing the trailer and finalizing the file. - # No explicit flush method is available or needed here. - logger.debug("Muxed final packets.") + # Close the container FIRST — this writes the final OGG page + # (or other format trailer) to the output buffer. For OGG/Opus, + # the last page of audio data is only written during close(). + self.container.close() + logger.debug("Closed container, final page/trailer written.") - # Get the final bytes from the buffer *before* closing it + # Now read the buffer which includes all trailing data data = self.output_buffer.getvalue() - self.close() # Close container and buffer + self.output_buffer.close() return data if audio_data is None or len(audio_data) == 0: From 5e55b5c05023c9a2338c4c4594522b75599cda84 Mon Sep 17 00:00:00 2001 From: JiminyScript Date: Wed, 22 Apr 2026 20:48:28 -0700 Subject: [PATCH 06/45] fix(web): add Firefox-safe audio playback fallback when MSE MP3 is unsupported --- web/src/services/AudioService.js | 223 ++++++++++++++++++------------- 1 file changed, 133 insertions(+), 90 deletions(-) diff --git a/web/src/services/AudioService.js b/web/src/services/AudioService.js index 64a6c4ac..7d321cb9 100644 --- a/web/src/services/AudioService.js +++ b/web/src/services/AudioService.js @@ -7,34 +7,53 @@ export class AudioService { this.audio = null; this.controller = null; this.eventListeners = new Map(); - this.minimumPlaybackSize = 50000; // 50KB minimum before playback + this.minimumPlaybackSize = 50000; this.textLength = 0; this.shouldAutoplay = false; - this.CHARS_PER_CHUNK = 150; // Estimated chars per chunk - this.serverDownloadPath = null; // Server-side download path - this.pendingOperations = []; // Queue for buffer operations + this.CHARS_PER_CHUNK = 150; + this.serverDownloadPath = null; + this.pendingOperations = []; + this.objectUrl = null; + } + + supportsMSEMp3() { + return ( + typeof window !== 'undefined' && + 'MediaSource' in window && + typeof MediaSource.isTypeSupported === 'function' && + MediaSource.isTypeSupported('audio/mpeg') + ); + } + + getPreferredStreamingMode() { + return this.supportsMSEMp3(); } async streamAudio(text, voice, speed, onProgress) { try { console.log('AudioService: Starting stream...', { text, voice, speed }); - + if (this.controller) { this.controller.abort(); this.controller = null; } - + this.controller = new AbortController(); this.cleanup(); - onProgress?.(0, 1); // Reset progress to 0 + onProgress?.(0, 1); this.textLength = text.length; this.shouldAutoplay = document.getElementById('autoplay-toggle').checked; - - // Calculate expected number of chunks based on text length + const estimatedChunks = Math.max(1, Math.ceil(this.textLength / this.CHARS_PER_CHUNK)); - - console.log('AudioService: Making API call...', { text, voice, speed }); - + const canStreamMp3 = this.getPreferredStreamingMode(); + + console.log('AudioService: Making API call...', { + text, + voice, + speed, + canStreamMp3 + }); + const apiUrl = await config.getApiUrl('/v1/audio/speech'); const response = await fetch(apiUrl, { method: 'POST', @@ -42,9 +61,9 @@ export class AudioService { body: JSON.stringify({ input: text, voice: voice, - response_format: 'mp3', // Always use mp3 for streaming playback - download_format: document.getElementById('format-select').value || 'mp3', // Format for final download - stream: true, + response_format: 'mp3', + download_format: document.getElementById('format-select').value || 'mp3', + stream: canStreamMp3, speed: speed, return_download_link: true, lang_code: document.getElementById('lang-select').value || undefined @@ -57,7 +76,6 @@ export class AudioService { headers: Object.fromEntries(response.headers.entries()) }); - // Check for download path as soon as we get the response const downloadPath = response.headers.get('x-download-path'); if (downloadPath) { this.serverDownloadPath = `/v1${downloadPath}`; @@ -78,13 +96,48 @@ export class AudioService { } } + async setupBlobPlayback(response, onProgress) { + this.audio = new Audio(); + + const blob = await response.blob(); + this.objectUrl = URL.createObjectURL(blob); + this.audio.src = this.objectUrl; + + this.audio.addEventListener('error', () => { + console.error('Audio error:', this.audio.error); + }); + + this.audio.addEventListener('ended', () => { + this.dispatchEvent('ended'); + }); + + this.audio.addEventListener('canplaythrough', () => { + if (this.shouldAutoplay) { + this.play(); + } + }, { once: true }); + + onProgress?.(1, 1); + this.dispatchEvent('complete'); + + setTimeout(() => { + this.dispatchEvent('downloadReady'); + }, 100); + } + async setupAudioStream(stream, response, onProgress, estimatedChunks) { + if (!this.supportsMSEMp3()) { + console.warn('MSE audio/mpeg not supported in this browser. Falling back to blob playback.'); + await this.setupBlobPlayback(response, onProgress); + return; + } + this.audio = new Audio(); this.mediaSource = new MediaSource(); - this.audio.src = URL.createObjectURL(this.mediaSource); - - // Monitor for audio element errors - this.audio.addEventListener('error', (e) => { + this.objectUrl = URL.createObjectURL(this.mediaSource); + this.audio.src = this.objectUrl; + + this.audio.addEventListener('error', () => { console.error('Audio error:', this.audio.error); }); @@ -97,17 +150,17 @@ export class AudioService { try { this.sourceBuffer = this.mediaSource.addSourceBuffer('audio/mpeg'); this.sourceBuffer.mode = 'sequence'; - + this.sourceBuffer.addEventListener('updateend', () => { this.processNextOperation(); }); - + await this.processStream(stream, response, onProgress, estimatedChunks); resolve(); } catch (error) { reject(error); } - }); + }, { once: true }); }); } @@ -118,16 +171,14 @@ export class AudioService { try { while (true) { - const {value, done} = await reader.read(); - + const { value, done } = await reader.read(); + if (done) { - // Get final download path from header after stream is complete const headers = Object.fromEntries(response.headers.entries()); console.log('Response headers at stream end:', headers); - + const downloadPath = headers['x-download-path']; if (downloadPath) { - // Use config to prepend root path and /v1 this.serverDownloadPath = await config.getApiUrl(`/v1${downloadPath}`); console.log('Download path received:', this.serverDownloadPath); } else { @@ -135,22 +186,26 @@ export class AudioService { Object.keys(headers).join(', ')); } - if (this.mediaSource.readyState === 'open') { + if (this.mediaSource && this.mediaSource.readyState === 'open') { this.mediaSource.endOfStream(); } - - // Signal completion + onProgress?.(estimatedChunks, estimatedChunks); this.dispatchEvent('complete'); - - // Check if we should autoplay for small inputs that didn't trigger during streaming - if (this.shouldAutoplay && !hasStartedPlaying && this.sourceBuffer.buffered.length > 0) { + + if ( + this.shouldAutoplay && + !hasStartedPlaying && + this.sourceBuffer && + this.sourceBuffer.buffered.length > 0 + ) { setTimeout(() => this.play(), 100); } - + setTimeout(() => { this.dispatchEvent('downloadReady'); }, 800); + return; } @@ -158,19 +213,15 @@ export class AudioService { onProgress?.(receivedChunks, estimatedChunks); try { - // Check for audio errors before proceeding - if (this.audio.error) { + if (this.audio?.error) { console.error('Audio error detected:', this.audio.error); - continue; // Skip this chunk if audio is in error state + continue; } - // Only remove old data if we're hitting quota errors - if (this.sourceBuffer.buffered.length > 0) { + if (this.sourceBuffer?.buffered.length > 0) { const currentTime = this.audio.currentTime; const start = this.sourceBuffer.buffered.start(0); - const end = this.sourceBuffer.buffered.end(0); - - // Only remove if we have a lot of historical data + if (currentTime - start > 30) { const removeEnd = Math.max(start, currentTime - 15); if (removeEnd > start) { @@ -181,7 +232,7 @@ export class AudioService { await this.appendChunk(value); - if (!hasStartedPlaying && this.sourceBuffer.buffered.length > 0) { + if (!hasStartedPlaying && this.sourceBuffer?.buffered.length > 0) { hasStartedPlaying = true; if (this.shouldAutoplay) { setTimeout(() => this.play(), 100); @@ -189,14 +240,12 @@ export class AudioService { } } catch (error) { if (error.name === 'QuotaExceededError') { - // If we hit quota, try more aggressive cleanup - if (this.sourceBuffer.buffered.length > 0) { + if (this.sourceBuffer?.buffered.length > 0) { const currentTime = this.audio.currentTime; const start = this.sourceBuffer.buffered.start(0); const removeEnd = Math.max(start, currentTime - 5); if (removeEnd > start) { await this.removeBufferRange(start, removeEnd); - // Retry append after removing data try { await this.appendChunk(value); } catch (retryError) { @@ -217,9 +266,12 @@ export class AudioService { } async removeBufferRange(start, end) { - // Double check that end is greater than start + if (!this.sourceBuffer) { + return; + } + if (end <= start) { - console.warn('Invalid buffer remove range:', {start, end}); + console.warn('Invalid buffer remove range:', { start, end }); return; } @@ -244,16 +296,19 @@ export class AudioService { } async appendChunk(chunk) { - // Don't append if audio is in error state - if (this.audio.error) { + if (!this.audio || this.audio.error) { console.warn('Skipping chunk append due to audio error'); return; } + if (!this.sourceBuffer) { + return; + } + return new Promise((resolve, reject) => { const operation = { chunk, resolve, reject }; this.pendingOperations.push(operation); - + if (!this.sourceBuffer.updating) { this.processNextOperation(); } @@ -261,13 +316,12 @@ export class AudioService { } processNextOperation() { - if (this.sourceBuffer.updating || this.pendingOperations.length === 0) { + if (!this.sourceBuffer || this.sourceBuffer.updating || this.pendingOperations.length === 0) { return; } - // Don't process if audio is in error state - if (this.audio.error) { - console.warn("Skipping operation due to audio error"); + if (!this.audio || this.audio.error) { + console.warn('Skipping operation due to audio error'); return; } @@ -276,37 +330,27 @@ export class AudioService { try { this.sourceBuffer.appendBuffer(operation.chunk); - // Set up event listeners const onUpdateEnd = () => { operation.resolve(); - this.sourceBuffer.removeEventListener("updateend", onUpdateEnd); - this.sourceBuffer.removeEventListener( - "updateerror", - onUpdateError - ); - // Process the next operation + this.sourceBuffer?.removeEventListener('updateend', onUpdateEnd); + this.sourceBuffer?.removeEventListener('updateerror', onUpdateError); this.processNextOperation(); }; const onUpdateError = (event) => { operation.reject(event); - this.sourceBuffer.removeEventListener("updateend", onUpdateEnd); - this.sourceBuffer.removeEventListener( - "updateerror", - onUpdateError - ); - // Decide whether to continue processing - if (event.name !== "InvalidStateError") { + this.sourceBuffer?.removeEventListener('updateend', onUpdateEnd); + this.sourceBuffer?.removeEventListener('updateerror', onUpdateError); + if (event.name !== 'InvalidStateError') { this.processNextOperation(); } }; - this.sourceBuffer.addEventListener("updateend", onUpdateEnd); - this.sourceBuffer.addEventListener("updateerror", onUpdateError); + this.sourceBuffer.addEventListener('updateend', onUpdateEnd); + this.sourceBuffer.addEventListener('updateerror', onUpdateError); } catch (error) { operation.reject(error); - // Only continue processing if it's not a fatal error - if (error.name !== "InvalidStateError") { + if (error.name !== 'InvalidStateError') { this.processNextOperation(); } } @@ -389,6 +433,13 @@ export class AudioService { } } + revokeObjectUrl() { + if (this.objectUrl) { + URL.revokeObjectURL(this.objectUrl); + this.objectUrl = null; + } + } + cancel() { if (this.controller) { this.controller.abort(); @@ -397,26 +448,22 @@ export class AudioService { if (this.audio) { this.audio.pause(); - this.audio.src = ""; + this.audio.src = ''; this.audio = null; } - if (this.mediaSource && this.mediaSource.readyState === "open") { + if (this.mediaSource && this.mediaSource.readyState === 'open') { try { this.mediaSource.endOfStream(); } catch (e) { - // Ignore errors during cleanup } } this.mediaSource = null; - if (this.sourceBuffer) { - this.sourceBuffer.removeEventListener("updateend", () => {}); - this.sourceBuffer.removeEventListener("updateerror", () => {}); - this.sourceBuffer = null; - } + this.sourceBuffer = null; this.serverDownloadPath = null; this.pendingOperations = []; + this.revokeObjectUrl(); } cleanup() { @@ -428,26 +475,22 @@ export class AudioService { }); this.audio.pause(); - this.audio.src = ""; + this.audio.src = ''; this.audio = null; } - if (this.mediaSource && this.mediaSource.readyState === "open") { + if (this.mediaSource && this.mediaSource.readyState === 'open') { try { this.mediaSource.endOfStream(); } catch (e) { - // Ignore errors during cleanup } } this.mediaSource = null; - if (this.sourceBuffer) { - this.sourceBuffer.removeEventListener("updateend", () => {}); - this.sourceBuffer.removeEventListener("updateerror", () => {}); - this.sourceBuffer = null; - } + this.sourceBuffer = null; this.serverDownloadPath = null; this.pendingOperations = []; + this.revokeObjectUrl(); } getDownloadUrl() { From b32d6b4d89f4e87feefce5f5503a73fa0997d0ac Mon Sep 17 00:00:00 2001 From: Niki Date: Fri, 8 May 2026 19:16:04 +0800 Subject: [PATCH 07/45] fix: cache voice tensors to prevent per-request memory leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #453 The generate() and generate_from_tokens() methods performed a full voice tensor round-trip on every TTS request: load from .pt file → deserialize to torch.Tensor → serialize back → write to new temp file. This created ~20-30MB of transient allocations per request that fragmented the Python heap, causing RSS to grow monotonically. Changes: - Add _voice_cache dict to KokoroV1 for in-memory voice tensor caching - Add _get_voice_tensor() helper with cache lookup by path+device - Replace direct load_voice_tensor() calls with cached version - Only save to temp file when the file doesn't already exist - Cache key includes device to handle GPU/CPU switches correctly --- api/src/inference/kokoro_v1.py | 40 ++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/api/src/inference/kokoro_v1.py b/api/src/inference/kokoro_v1.py index a627dbb3..f725e8ae 100644 --- a/api/src/inference/kokoro_v1.py +++ b/api/src/inference/kokoro_v1.py @@ -25,6 +25,24 @@ def __init__(self): self._device = settings.get_device() self._model: Optional[KModel] = None self._pipelines: Dict[str, KPipeline] = {} # Store pipelines by lang_code + self._voice_cache: Dict[str, torch.Tensor] = {} # Cache voice tensors by path + + async def _get_voice_tensor(self, voice_path: str) -> torch.Tensor: + """Load voice tensor with in-memory caching to avoid repeated file I/O. + + Args: + voice_path: Path to the .pt voice file + + Returns: + Voice tensor on the target device + """ + cache_key = f"{voice_path}:{self._device}" + if cache_key not in self._voice_cache: + self._voice_cache[cache_key] = await paths.load_voice_tensor( + voice_path, device=self._device + ) + logger.debug(f"Cached voice tensor from {voice_path}") + return self._voice_cache[cache_key] async def load_model(self, path: str) -> None: """Load pre-baked model. @@ -133,18 +151,17 @@ async def generate_from_tokens( voice_path = voice voice_name = os.path.splitext(os.path.basename(voice_path))[0] - # Load voice tensor with proper device mapping - voice_tensor = await paths.load_voice_tensor( - voice_path, device=self._device - ) - # Save back to a temporary file with proper device mapping + # Load voice tensor with caching to avoid repeated file I/O + voice_tensor = await self._get_voice_tensor(voice_path) + # Save to temp file only if needed (pipeline requires a file path) import tempfile temp_dir = tempfile.gettempdir() temp_path = os.path.join( temp_dir, f"temp_voice_{os.path.basename(voice_path)}" ) - await paths.save_voice_tensor(voice_tensor, temp_path) + if not os.path.exists(temp_path): + await paths.save_voice_tensor(voice_tensor, temp_path) voice_path = temp_path # Use provided lang_code, settings voice code override, or first letter of voice name @@ -232,18 +249,17 @@ async def generate( voice_path = voice voice_name = os.path.splitext(os.path.basename(voice_path))[0] - # Load voice tensor with proper device mapping - voice_tensor = await paths.load_voice_tensor( - voice_path, device=self._device - ) - # Save back to a temporary file with proper device mapping + # Load voice tensor with caching to avoid repeated file I/O + voice_tensor = await self._get_voice_tensor(voice_path) + # Save to temp file only if needed (pipeline requires a file path) import tempfile temp_dir = tempfile.gettempdir() temp_path = os.path.join( temp_dir, f"temp_voice_{os.path.basename(voice_path)}" ) - await paths.save_voice_tensor(voice_tensor, temp_path) + if not os.path.exists(temp_path): + await paths.save_voice_tensor(voice_tensor, temp_path) voice_path = temp_path # Use provided lang_code, settings voice code override, or first letter of voice name From 878fd5d02857e77d7594b7f0f125ae0cf464d154 Mon Sep 17 00:00:00 2001 From: Cao Hanzhe Date: Fri, 8 May 2026 23:10:18 +0800 Subject: [PATCH 08/45] fix: use weights_only=True for voice tensor torch.load calls Addresses remsky/Kokoro-FastAPI#452 Voice .pt files contain simple tensors and do not require pickle deserialization. Using weights_only=False allows arbitrary code execution through crafted pickle payloads. This change sets weights_only=True on all torch.load() calls for voice tensors, matching the behavior already used for model weight loading in load_model_weights(). Changes: - api/src/core/paths.py: Change load_voice_tensor default from weights_only=False to weights_only=True - api/src/services/tts_service.py: Add weights_only=True to torch.load call in _load_voice --- api/src/core/paths.py | 2 +- api/src/services/tts_service.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/core/paths.py b/api/src/core/paths.py index 771b70c3..a2fc60b5 100644 --- a/api/src/core/paths.py +++ b/api/src/core/paths.py @@ -160,7 +160,7 @@ def filter_voice_files(name: str) -> bool: async def load_voice_tensor( - voice_path: str, device: str = "cpu", weights_only=False + voice_path: str, device: str = "cpu", weights_only=True ) -> torch.Tensor: """Load voice tensor from file. diff --git a/api/src/services/tts_service.py b/api/src/services/tts_service.py index 46c2fb4c..1ac74557 100644 --- a/api/src/services/tts_service.py +++ b/api/src/services/tts_service.py @@ -174,7 +174,7 @@ async def _load_voice_from_path(self, path: str, weight: float): raise ValueError(f"Voice not found at path: {path}") logger.debug(f"Loading voice tensor from path: {path}") - return torch.load(path, map_location="cpu") * weight + return torch.load(path, map_location="cpu", weights_only=True) * weight async def _get_voices_path(self, voice: str) -> Tuple[str, str]: """Get voice path, handling combined voices. From a82860bbcc7b0296928a2d436f80a27be8d65709 Mon Sep 17 00:00:00 2001 From: remsky Date: Sat, 9 May 2026 18:33:51 -0600 Subject: [PATCH 09/45] fix: clear voice cache on unload, correct temp file age check, align tests --- .gitignore | 6 ++++++ api/src/core/paths.py | 6 ++++-- api/src/inference/kokoro_v1.py | 1 + api/tests/test_kokoro_v1.py | 24 +++++++++++++----------- api/tests/test_openai_endpoints.py | 3 +-- api/tests/test_paths.py | 5 +++-- api/tests/test_tts_service.py | 5 +++-- 7 files changed, 31 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index 35fa9fb8..b5c84c65 100644 --- a/.gitignore +++ b/.gitignore @@ -69,7 +69,13 @@ examples/*.ogg examples/speech.mp3 examples/phoneme_examples/output/*.wav examples/assorted_checks/benchmarks/output_audio/* +examples/assorted_checks/test_transcription/output/*.wav +examples/assorted_checks/test_transcription/output_long_form/*.log +examples/assorted_checks/test_transcription/output_long_form/*.transcript.txt +examples/assorted_checks/test_transcription/output_long_form/*.wav uv.lock # Mac MPS virtualenv for dual testing .venv-mps +.claude/block-remote.ps1 +.claude/settings.local.json diff --git a/api/src/core/paths.py b/api/src/core/paths.py index a2fc60b5..736d8db0 100644 --- a/api/src/core/paths.py +++ b/api/src/core/paths.py @@ -3,6 +3,7 @@ import io import json import os +import time from pathlib import Path from typing import Any, AsyncIterator, Callable, Dict, List, Optional, Set @@ -344,12 +345,13 @@ async def cleanup_temp_files() -> None: await aiofiles.os.makedirs(settings.temp_file_dir, exist_ok=True) return + now = time.time() + max_age = settings.max_temp_dir_age_hours * 3600 entries = await aiofiles.os.scandir(settings.temp_file_dir) for entry in entries: if entry.is_file(): stat = await aiofiles.os.stat(entry.path) - max_age = stat.st_mtime + (settings.max_temp_dir_age_hours * 3600) - if max_age < stat.st_mtime: + if now - stat.st_mtime > max_age: try: await aiofiles.os.remove(entry.path) logger.info(f"Cleaned up old temp file: {entry.name}") diff --git a/api/src/inference/kokoro_v1.py b/api/src/inference/kokoro_v1.py index f725e8ae..5b201008 100644 --- a/api/src/inference/kokoro_v1.py +++ b/api/src/inference/kokoro_v1.py @@ -371,6 +371,7 @@ def unload(self) -> None: for pipeline in self._pipelines.values(): del pipeline self._pipelines.clear() + self._voice_cache.clear() if torch.cuda.is_available(): torch.cuda.empty_cache() torch.cuda.synchronize() diff --git a/api/tests/test_kokoro_v1.py b/api/tests/test_kokoro_v1.py index 6891d8d3..ff3fc072 100644 --- a/api/tests/test_kokoro_v1.py +++ b/api/tests/test_kokoro_v1.py @@ -1,3 +1,5 @@ +import tempfile +from pathlib import Path from unittest.mock import ANY, MagicMock, patch import numpy as np @@ -49,8 +51,8 @@ def test_clear_memory(mock_sync, mock_clear, kokoro_backend): @pytest.mark.asyncio async def test_load_model_validation(kokoro_backend): - """Test model loading validation.""" - with pytest.raises(RuntimeError, match="Failed to load Kokoro model"): + """Test model loading validation: missing file surfaces FileNotFoundError unwrapped.""" + with pytest.raises(FileNotFoundError): await kokoro_backend.load_model("nonexistent_model.pth") @@ -61,6 +63,7 @@ def test_unload_with_pipelines(kokoro_backend): pipeline_a = MagicMock() pipeline_e = MagicMock() kokoro_backend._pipelines = {"a": pipeline_a, "e": pipeline_e} + kokoro_backend._voice_cache = {"af_heart.pt:cpu": MagicMock()} assert kokoro_backend.is_loaded # Test unload @@ -68,6 +71,7 @@ def test_unload_with_pipelines(kokoro_backend): assert not kokoro_backend.is_loaded assert kokoro_backend._model is None assert kokoro_backend._pipelines == {} # All pipelines should be cleared + assert kokoro_backend._voice_cache == {} # Voice tensors should be released @pytest.mark.asyncio @@ -137,10 +141,8 @@ async def test_generate_uses_correct_pipeline(kokoro_backend): with ( patch("api.src.core.paths.load_voice_tensor") as mock_load_voice, patch("api.src.core.paths.save_voice_tensor"), - patch("tempfile.gettempdir") as mock_tempdir, ): mock_load_voice.return_value = torch.ones(1) - mock_tempdir.return_value = "/tmp" # Mock KPipeline mock_pipeline = MagicMock() @@ -150,16 +152,16 @@ async def test_generate_uses_correct_pipeline(kokoro_backend): async for _ in kokoro_backend.generate("test", "ef_voice", lang_code="e"): pass - # Should create pipeline with Spanish lang_code + # Should create pipeline with Spanish lang_code and call it. assert "e" in kokoro_backend._pipelines - # Use ANY to match the temp file path since it's dynamic mock_pipeline.assert_called_with( "test", - voice=ANY, # Don't check exact path since it's dynamic + voice=ANY, speed=1.0, model=kokoro_backend._model, ) - # Verify the voice path is a temp file path - call_args = mock_pipeline.call_args - assert isinstance(call_args[1]["voice"], str) - assert call_args[1]["voice"].startswith("/tmp/temp_voice_") + # Voice was staged to a temp file with the expected basename in + # the OS temp dir (cross-platform: don't string-match separators). + voice_arg = Path(mock_pipeline.call_args[1]["voice"]) + assert voice_arg.name == "temp_voice_ef_voice" + assert voice_arg.parent == Path(tempfile.gettempdir()) diff --git a/api/tests/test_openai_endpoints.py b/api/tests/test_openai_endpoints.py index d5c7efcb..00e27d63 100644 --- a/api/tests/test_openai_endpoints.py +++ b/api/tests/test_openai_endpoints.py @@ -78,13 +78,12 @@ def test_list_models(mock_openai_mappings): data = response.json() assert data["object"] == "list" assert isinstance(data["data"], list) - assert len(data["data"]) == 3 # tts-1, tts-1-hd, and kokoro - # Verify all expected models are present model_ids = [model["id"] for model in data["data"]] assert "tts-1" in model_ids assert "tts-1-hd" in model_ids assert "kokoro" in model_ids + assert "gpt-4o-mini-tts" in model_ids # Verify model format for model in data["data"]: diff --git a/api/tests/test_paths.py b/api/tests/test_paths.py index 715934e7..29cebc58 100644 --- a/api/tests/test_paths.py +++ b/api/tests/test_paths.py @@ -1,4 +1,5 @@ import os +from pathlib import Path from unittest.mock import patch import pytest @@ -19,7 +20,7 @@ async def test_find_file_exists(): with patch("aiofiles.os.path.exists") as mock_exists: mock_exists.return_value = True path = await _find_file("test.txt", ["/test/path"]) - assert path == "/test/path/test.txt" + assert Path(path) == Path("/test/path/test.txt") @pytest.mark.asyncio @@ -38,7 +39,7 @@ async def test_find_file_with_filter(): mock_exists.return_value = True filter_fn = lambda p: p.endswith(".txt") path = await _find_file("test.txt", ["/test/path"], filter_fn) - assert path == "/test/path/test.txt" + assert Path(path) == Path("/test/path/test.txt") @pytest.mark.asyncio diff --git a/api/tests/test_tts_service.py b/api/tests/test_tts_service.py index 968c31f0..35c2d0fd 100644 --- a/api/tests/test_tts_service.py +++ b/api/tests/test_tts_service.py @@ -1,3 +1,4 @@ +from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import numpy as np @@ -104,8 +105,8 @@ async def test_get_voice_path_combined(): name, path = await service._get_voices_path("voice1+voice2") assert name == "voice1+voice2" # Verify the path points to a temporary file with expected format - assert path.startswith("/tmp/") - assert "voice1+voice2" in path + assert Path(path).parent == Path("/tmp") + assert "voice1+voice2" in Path(path).name assert path.endswith(".pt") mock_save.assert_called_once() From 1e09bfbbdcb21eca1bbe7261117ba5c5d8201277 Mon Sep 17 00:00:00 2001 From: remsky Date: Sat, 9 May 2026 23:58:12 -0600 Subject: [PATCH 10/45] examples: add transcription accuracy test harness with baselines --- .../benchmark_first_token_stream_unified.py | 7 +- .../first_token_benchmark_stream.json | 50 +-- .../first_token_benchmark_stream_openai.json | 50 +-- .../test_transcription/BASELINE.md | 37 +++ .../test_transcription/README.md | 29 ++ .../test_transcription/__init__.py | 0 .../input/journey_all.txt.gz | Bin 0 -> 198483 bytes .../test_transcription/output/report.json | 42 +++ .../output_long_form/long_form_report.json | 18 + .../test_transcription/run_long_form.bat | 57 ++++ .../test_transcription/test_long_form.py | 311 ++++++++++++++++++ .../test_transcription/test_transcription.py | 175 ++++++++++ examples/assorted_checks/validate_wav.py | 2 +- examples/pyproject.toml | 30 ++ 14 files changed, 755 insertions(+), 53 deletions(-) create mode 100644 examples/assorted_checks/test_transcription/BASELINE.md create mode 100644 examples/assorted_checks/test_transcription/README.md create mode 100644 examples/assorted_checks/test_transcription/__init__.py create mode 100644 examples/assorted_checks/test_transcription/input/journey_all.txt.gz create mode 100644 examples/assorted_checks/test_transcription/output/report.json create mode 100644 examples/assorted_checks/test_transcription/output_long_form/long_form_report.json create mode 100644 examples/assorted_checks/test_transcription/run_long_form.bat create mode 100644 examples/assorted_checks/test_transcription/test_long_form.py create mode 100644 examples/assorted_checks/test_transcription/test_transcription.py create mode 100644 examples/pyproject.toml diff --git a/examples/assorted_checks/benchmarks/benchmark_first_token_stream_unified.py b/examples/assorted_checks/benchmarks/benchmark_first_token_stream_unified.py index c3b6d922..07c4cac1 100644 --- a/examples/assorted_checks/benchmarks/benchmark_first_token_stream_unified.py +++ b/examples/assorted_checks/benchmarks/benchmark_first_token_stream_unified.py @@ -10,6 +10,9 @@ base_url="http://localhost:8880/v1", api_key="not-needed-for-local" ) +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +EXAMPLES_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, os.pardir, os.pardir)) + def measure_first_token_requests( text: str, output_dir: str, tokens: int, run_number: int @@ -46,7 +49,7 @@ def measure_first_token_requests( # Save complete audio audio_filename = f"benchmark_tokens{tokens}_run{run_number}_stream.wav" audio_path = os.path.join(output_dir, audio_filename) - results["audio_path"] = audio_path + results["audio_path"] = os.path.relpath(audio_path, EXAMPLES_DIR) first_chunk_time = None chunks = [] @@ -114,7 +117,7 @@ def measure_first_token_openai( # Save complete audio audio_filename = f"benchmark_tokens{tokens}_run{run_number}_stream_openai.wav" audio_path = os.path.join(output_dir, audio_filename) - results["audio_path"] = audio_path + results["audio_path"] = os.path.relpath(audio_path, EXAMPLES_DIR) first_chunk_time = None all_audio_data = bytearray() diff --git a/examples/assorted_checks/benchmarks/output_data/first_token_benchmark_stream.json b/examples/assorted_checks/benchmarks/output_data/first_token_benchmark_stream.json index c78b5ab3..600e32de 100644 --- a/examples/assorted_checks/benchmarks/output_data/first_token_benchmark_stream.json +++ b/examples/assorted_checks/benchmarks/output_data/first_token_benchmark_stream.json @@ -6,7 +6,7 @@ "total_time": 1.818483829498291, "time_to_first_chunk": 1.8067498207092285, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run1_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run1_stream.wav", "audio_length": 3.45, "target_tokens": 10, "actual_tokens": 10, @@ -18,7 +18,7 @@ "total_time": 1.6271553039550781, "time_to_first_chunk": 1.610968828201294, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run2_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run2_stream.wav", "audio_length": 3.45, "target_tokens": 10, "actual_tokens": 10, @@ -30,7 +30,7 @@ "total_time": 1.5759549140930176, "time_to_first_chunk": 1.561316967010498, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run3_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run3_stream.wav", "audio_length": 3.45, "target_tokens": 10, "actual_tokens": 10, @@ -42,7 +42,7 @@ "total_time": 1.615680456161499, "time_to_first_chunk": 1.6035709381103516, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run4_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run4_stream.wav", "audio_length": 3.45, "target_tokens": 10, "actual_tokens": 10, @@ -54,7 +54,7 @@ "total_time": 1.6515357494354248, "time_to_first_chunk": 1.6268820762634277, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run5_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens10_run5_stream.wav", "audio_length": 3.45, "target_tokens": 10, "actual_tokens": 10, @@ -66,7 +66,7 @@ "total_time": 7.368175268173218, "time_to_first_chunk": 3.4540352821350098, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run1_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run1_stream.wav", "audio_length": 15.825, "target_tokens": 50, "actual_tokens": 50, @@ -78,7 +78,7 @@ "total_time": 6.931752443313599, "time_to_first_chunk": 3.1553661823272705, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run2_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run2_stream.wav", "audio_length": 15.825, "target_tokens": 50, "actual_tokens": 50, @@ -90,7 +90,7 @@ "total_time": 6.867500066757202, "time_to_first_chunk": 3.127124309539795, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run3_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run3_stream.wav", "audio_length": 15.825, "target_tokens": 50, "actual_tokens": 50, @@ -102,7 +102,7 @@ "total_time": 6.933881521224976, "time_to_first_chunk": 3.1872360706329346, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run4_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run4_stream.wav", "audio_length": 15.825, "target_tokens": 50, "actual_tokens": 50, @@ -114,7 +114,7 @@ "total_time": 7.605916738510132, "time_to_first_chunk": 3.6397976875305176, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run5_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens50_run5_stream.wav", "audio_length": 15.825, "target_tokens": 50, "actual_tokens": 50, @@ -126,7 +126,7 @@ "total_time": 14.777218580245972, "time_to_first_chunk": 3.625889778137207, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run1_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run1_stream.wav", "audio_length": 30.35, "target_tokens": 100, "actual_tokens": 100, @@ -138,7 +138,7 @@ "total_time": 13.911701202392578, "time_to_first_chunk": 3.298157215118408, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run2_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run2_stream.wav", "audio_length": 30.35, "target_tokens": 100, "actual_tokens": 100, @@ -150,7 +150,7 @@ "total_time": 14.451806783676147, "time_to_first_chunk": 3.8353848457336426, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run3_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run3_stream.wav", "audio_length": 30.35, "target_tokens": 100, "actual_tokens": 100, @@ -162,7 +162,7 @@ "total_time": 13.941124200820923, "time_to_first_chunk": 3.3754897117614746, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run4_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run4_stream.wav", "audio_length": 30.35, "target_tokens": 100, "actual_tokens": 100, @@ -174,7 +174,7 @@ "total_time": 15.717307329177856, "time_to_first_chunk": 3.6421003341674805, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run5_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens100_run5_stream.wav", "audio_length": 30.35, "target_tokens": 100, "actual_tokens": 100, @@ -186,7 +186,7 @@ "total_time": 41.16162133216858, "time_to_first_chunk": 3.7044918537139893, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run1_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run1_stream.wav", "audio_length": 78.175, "target_tokens": 250, "actual_tokens": 250, @@ -198,7 +198,7 @@ "total_time": 35.43009877204895, "time_to_first_chunk": 3.1040024757385254, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run2_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run2_stream.wav", "audio_length": 78.175, "target_tokens": 250, "actual_tokens": 250, @@ -210,7 +210,7 @@ "total_time": 35.285505294799805, "time_to_first_chunk": 3.657808780670166, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run3_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run3_stream.wav", "audio_length": 78.175, "target_tokens": 250, "actual_tokens": 250, @@ -222,7 +222,7 @@ "total_time": 34.47842836380005, "time_to_first_chunk": 3.2033851146698, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run4_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run4_stream.wav", "audio_length": 78.175, "target_tokens": 250, "actual_tokens": 250, @@ -234,7 +234,7 @@ "total_time": 36.50936222076416, "time_to_first_chunk": 3.1159815788269043, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run5_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens250_run5_stream.wav", "audio_length": 78.175, "target_tokens": 250, "actual_tokens": 250, @@ -246,7 +246,7 @@ "total_time": 86.84899735450745, "time_to_first_chunk": 5.405678987503052, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run1_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run1_stream.wav", "audio_length": 155.125, "target_tokens": 500, "actual_tokens": 500, @@ -258,7 +258,7 @@ "total_time": 74.72578477859497, "time_to_first_chunk": 3.966891050338745, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run2_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run2_stream.wav", "audio_length": 155.125, "target_tokens": 500, "actual_tokens": 500, @@ -270,7 +270,7 @@ "total_time": 68.1974081993103, "time_to_first_chunk": 3.27712082862854, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run3_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run3_stream.wav", "audio_length": 155.125, "target_tokens": 500, "actual_tokens": 500, @@ -282,7 +282,7 @@ "total_time": 72.68819260597229, "time_to_first_chunk": 3.153608560562134, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run4_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run4_stream.wav", "audio_length": 155.125, "target_tokens": 500, "actual_tokens": 500, @@ -294,7 +294,7 @@ "total_time": 67.94887590408325, "time_to_first_chunk": 3.954728841781616, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run5_stream.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream\\benchmark_tokens500_run5_stream.wav", "audio_length": 155.125, "target_tokens": 500, "actual_tokens": 500, diff --git a/examples/assorted_checks/benchmarks/output_data/first_token_benchmark_stream_openai.json b/examples/assorted_checks/benchmarks/output_data/first_token_benchmark_stream_openai.json index 968fffbb..a13f4715 100644 --- a/examples/assorted_checks/benchmarks/output_data/first_token_benchmark_stream_openai.json +++ b/examples/assorted_checks/benchmarks/output_data/first_token_benchmark_stream_openai.json @@ -6,7 +6,7 @@ "total_time": 1.638200044631958, "time_to_first_chunk": 1.6232295036315918, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run1_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run1_stream_openai.wav", "audio_length": 3.45, "target_tokens": 10, "actual_tokens": 10, @@ -18,7 +18,7 @@ "total_time": 1.4960439205169678, "time_to_first_chunk": 1.4854960441589355, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run2_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run2_stream_openai.wav", "audio_length": 3.45, "target_tokens": 10, "actual_tokens": 10, @@ -30,7 +30,7 @@ "total_time": 1.5055279731750488, "time_to_first_chunk": 1.4948456287384033, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run3_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run3_stream_openai.wav", "audio_length": 3.45, "target_tokens": 10, "actual_tokens": 10, @@ -42,7 +42,7 @@ "total_time": 1.496837854385376, "time_to_first_chunk": 1.4835176467895508, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run4_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run4_stream_openai.wav", "audio_length": 3.45, "target_tokens": 10, "actual_tokens": 10, @@ -54,7 +54,7 @@ "total_time": 1.7330272197723389, "time_to_first_chunk": 1.7219843864440918, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run5_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens10_run5_stream_openai.wav", "audio_length": 3.45, "target_tokens": 10, "actual_tokens": 10, @@ -66,7 +66,7 @@ "total_time": 6.865253925323486, "time_to_first_chunk": 3.1809072494506836, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run1_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run1_stream_openai.wav", "audio_length": 15.825, "target_tokens": 50, "actual_tokens": 50, @@ -78,7 +78,7 @@ "total_time": 7.975425720214844, "time_to_first_chunk": 3.2910428047180176, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run2_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run2_stream_openai.wav", "audio_length": 15.825, "target_tokens": 50, "actual_tokens": 50, @@ -90,7 +90,7 @@ "total_time": 6.793715715408325, "time_to_first_chunk": 3.210068464279175, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run3_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run3_stream_openai.wav", "audio_length": 15.825, "target_tokens": 50, "actual_tokens": 50, @@ -102,7 +102,7 @@ "total_time": 6.639606237411499, "time_to_first_chunk": 3.0641400814056396, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run4_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run4_stream_openai.wav", "audio_length": 15.825, "target_tokens": 50, "actual_tokens": 50, @@ -114,7 +114,7 @@ "total_time": 8.100529193878174, "time_to_first_chunk": 3.3910109996795654, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run5_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens50_run5_stream_openai.wav", "audio_length": 15.825, "target_tokens": 50, "actual_tokens": 50, @@ -126,7 +126,7 @@ "total_time": 15.246968984603882, "time_to_first_chunk": 3.1980819702148438, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run1_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run1_stream_openai.wav", "audio_length": 30.35, "target_tokens": 100, "actual_tokens": 100, @@ -138,7 +138,7 @@ "total_time": 15.934760332107544, "time_to_first_chunk": 4.23082709312439, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run2_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run2_stream_openai.wav", "audio_length": 30.35, "target_tokens": 100, "actual_tokens": 100, @@ -150,7 +150,7 @@ "total_time": 13.799078226089478, "time_to_first_chunk": 3.42996883392334, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run3_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run3_stream_openai.wav", "audio_length": 30.35, "target_tokens": 100, "actual_tokens": 100, @@ -162,7 +162,7 @@ "total_time": 13.400063037872314, "time_to_first_chunk": 3.2097883224487305, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run4_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run4_stream_openai.wav", "audio_length": 30.35, "target_tokens": 100, "actual_tokens": 100, @@ -174,7 +174,7 @@ "total_time": 14.833694219589233, "time_to_first_chunk": 3.1589744091033936, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run5_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens100_run5_stream_openai.wav", "audio_length": 30.35, "target_tokens": 100, "actual_tokens": 100, @@ -186,7 +186,7 @@ "total_time": 35.49378156661987, "time_to_first_chunk": 3.852027177810669, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run1_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run1_stream_openai.wav", "audio_length": 78.175, "target_tokens": 250, "actual_tokens": 250, @@ -198,7 +198,7 @@ "total_time": 33.59433174133301, "time_to_first_chunk": 3.2059006690979004, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run2_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run2_stream_openai.wav", "audio_length": 78.175, "target_tokens": 250, "actual_tokens": 250, @@ -210,7 +210,7 @@ "total_time": 34.23120045661926, "time_to_first_chunk": 3.1464977264404297, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run3_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run3_stream_openai.wav", "audio_length": 78.175, "target_tokens": 250, "actual_tokens": 250, @@ -222,7 +222,7 @@ "total_time": 36.18487215042114, "time_to_first_chunk": 3.188844919204712, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run4_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run4_stream_openai.wav", "audio_length": 78.175, "target_tokens": 250, "actual_tokens": 250, @@ -234,7 +234,7 @@ "total_time": 38.142744302749634, "time_to_first_chunk": 3.6997063159942627, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run5_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens250_run5_stream_openai.wav", "audio_length": 78.175, "target_tokens": 250, "actual_tokens": 250, @@ -246,7 +246,7 @@ "total_time": 71.48920440673828, "time_to_first_chunk": 3.148237943649292, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run1_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run1_stream_openai.wav", "audio_length": 155.125, "target_tokens": 500, "actual_tokens": 500, @@ -258,7 +258,7 @@ "total_time": 73.53017520904541, "time_to_first_chunk": 3.464594841003418, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run2_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run2_stream_openai.wav", "audio_length": 155.125, "target_tokens": 500, "actual_tokens": 500, @@ -270,7 +270,7 @@ "total_time": 75.52278685569763, "time_to_first_chunk": 3.5506417751312256, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run3_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run3_stream_openai.wav", "audio_length": 155.125, "target_tokens": 500, "actual_tokens": 500, @@ -282,7 +282,7 @@ "total_time": 69.45922994613647, "time_to_first_chunk": 3.495962619781494, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run4_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run4_stream_openai.wav", "audio_length": 155.125, "target_tokens": 500, "actual_tokens": 500, @@ -294,7 +294,7 @@ "total_time": 66.66928672790527, "time_to_first_chunk": 3.301323175430298, "error": null, - "audio_path": "c:\\Users\\jerem\\Desktop\\Kokoro-FastAPI\\examples\\assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run5_stream_openai.wav", + "audio_path": "assorted_checks\\benchmarks\\output_audio_stream_openai\\benchmark_tokens500_run5_stream_openai.wav", "audio_length": 155.125, "target_tokens": 500, "actual_tokens": 500, diff --git a/examples/assorted_checks/test_transcription/BASELINE.md b/examples/assorted_checks/test_transcription/BASELINE.md new file mode 100644 index 00000000..12d46508 --- /dev/null +++ b/examples/assorted_checks/test_transcription/BASELINE.md @@ -0,0 +1,37 @@ +# Long-Form Baseline + +Reference numbers from `test_long_form.py` against a known-good build, so future +runs have something to compare against. + +## Setup + +- Voice: `af_heart` +- Server: local, GPU +- Whisper: `base.en`, CPU, int8, VAD on +- Output format: 24 kHz mono PCM WAV +- Source: `input/journey_all.txt.gz` — Project Gutenberg, *A Journey to the Centre of the Earth* (Jules Verne) + +Both runs use the same source. The short run caps the cleaned input at 65,000 chars (`--chars 65000`), which lands at roughly end-of-chapter-7. The full run uses the entire book. + +## Numbers + +| Metric | Short (`--chars 65000`) | Full book | +| --- | --- | --- | +| Input words / chars | 11,468 / 64,996 | 88,884 / 502,766 | +| Synth time | 1m21s | 10m13s | +| Audio length | 66m06s | 507m52s | +| Synth speedup | 48.6× realtime | 49.7× realtime | +| Output size | 181.6 MB | 1394.9 MB | +| Transcribe time | 4m54s | 31m24s | +| Transcribe speedup | 13.5× realtime | 16.2× realtime | +| Transcript words | 11,546 | 89,300 | +| **WER (normalized)** | **0.0481** | **0.0339** | + +## Regression thresholds + +A fresh run on the same input + voice + Whisper config should land near the table above. Loose bands: + +- WER < 0.06 (current: 0.034–0.048) +- Transcript word count within **±1%** of cleaned input +- Synth realtime factor in the 40–60× range (GPU-dependent; a 10× regression is worth a look) +- Transcribe realtime factor in the 13–17× range (CPU-dependent on `base.en` int8) diff --git a/examples/assorted_checks/test_transcription/README.md b/examples/assorted_checks/test_transcription/README.md new file mode 100644 index 00000000..dbb5eed1 --- /dev/null +++ b/examples/assorted_checks/test_transcription/README.md @@ -0,0 +1,29 @@ +# Transcription Roundtrip Check + +Synthesizes a few phrases with a running Kokoro server, transcribes the audio +locally with [`faster-whisper`](https://github.com/SYSTRAN/faster-whisper), +and reports word error rate (WER) against the expected text. + +## Run + +From the `examples/` directory: + +```bash +uv sync --extra transcription +uv run python assorted_checks/test_transcription/test_transcription.py +``` + +First run downloads the Whisper model (~150 MB for `base.en`) into the HF cache. + +## Config (env vars) + +| Var | Default | Notes | +| --- | --- | --- | +| `KOKORO_BASE_URL` | `http://localhost:8880/v1` | Running Kokoro server | +| `WHISPER_MODEL` | `base.en` | Try `tiny.en` for speed, `small.en` for accuracy | +| `WER_THRESHOLD` | `0.2` | Per-clip pass cutoff | + +## Output + +WAVs and a `report.json` are written to `output/`. Exit code is `0` if every +case passed. diff --git a/examples/assorted_checks/test_transcription/__init__.py b/examples/assorted_checks/test_transcription/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/assorted_checks/test_transcription/input/journey_all.txt.gz b/examples/assorted_checks/test_transcription/input/journey_all.txt.gz new file mode 100644 index 0000000000000000000000000000000000000000..b54e7c53af1d1086ffd06c50cb8daf1494d31ed2 GIT binary patch literal 198483 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 X=c^KzBFz(_iFAZrJ=-*zP31(z&cR-talB$K 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

krabm=5p9;DY}Y*KI%JJ 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�J;R$#M9RSy|1do}XVCh>*NYs#yTpVIf7 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 R=a}-y{|7)0!nMQZ2LLnIdoln3 literal 0 HcmV?d00001 diff --git a/examples/assorted_checks/test_transcription/output/report.json b/examples/assorted_checks/test_transcription/output/report.json new file mode 100644 index 00000000..9ec77ed7 --- /dev/null +++ b/examples/assorted_checks/test_transcription/output/report.json @@ -0,0 +1,42 @@ +[ + { + "voice": "af_heart", + "expected": "The quick brown fox jumps over the lazy dog.", + "transcript": "The quick brown fox jumps over the lazy dog.", + "wer": 0.0, + "passed": true, + "audio_path": "output/af_heart.wav", + "synth_seconds": 2.286747300007846, + "transcribe_seconds": 1.1613717000000179 + }, + { + "voice": "af_bella", + "expected": "She sells seashells by the seashore.", + "transcript": "She sells seashells by the seashore.", + "wer": 0.0, + "passed": true, + "audio_path": "output/af_bella.wav", + "synth_seconds": 1.3922755000094185, + "transcribe_seconds": 1.0665276000072481 + }, + { + "voice": "am_adam", + "expected": "Pack my box with five dozen liquor jugs.", + "transcript": "Pack my box with 5 dozen liquor jugs.", + "wer": 0.0, + "passed": true, + "audio_path": "output/am_adam.wav", + "synth_seconds": 1.4518334999884246, + "transcribe_seconds": 0.9967074000014691 + }, + { + "voice": "af_nicole", + "expected": "The five boxing wizards jump quickly across the field.", + "transcript": "the five boxing wizards. Jump quickly across the field.", + "wer": 0.0, + "passed": true, + "audio_path": "output/af_nicole.wav", + "synth_seconds": 2.4623890000075335, + "transcribe_seconds": 0.9693172000115737 + } +] \ No newline at end of file diff --git a/examples/assorted_checks/test_transcription/output_long_form/long_form_report.json b/examples/assorted_checks/test_transcription/output_long_form/long_form_report.json new file mode 100644 index 00000000..77fac1b2 --- /dev/null +++ b/examples/assorted_checks/test_transcription/output_long_form/long_form_report.json @@ -0,0 +1,18 @@ +{ + "voice": "af_heart", + "input_file": "input/journey_all.txt.gz", + "input_words": 11468, + "input_chars": 64996, + "input_chars_cap": 65000, + "audio_file": "output_long_form/long_form_af_heart.wav", + "audio_seconds": 3966.46, + "synth_seconds": 81.65, + "synth_realtime_factor": 48.58, + "audio_file_mb": 181.57, + "whisper_model": "base.en", + "transcribe_seconds": 294.12, + "transcribe_realtime_factor": 13.49, + "transcript_words": 11546, + "wer": 0.0481, + "transcript_file": "output_long_form/long_form_af_heart.transcript.txt" +} \ No newline at end of file diff --git a/examples/assorted_checks/test_transcription/run_long_form.bat b/examples/assorted_checks/test_transcription/run_long_form.bat new file mode 100644 index 00000000..a4c46dcc --- /dev/null +++ b/examples/assorted_checks/test_transcription/run_long_form.bat @@ -0,0 +1,57 @@ +@echo off +REM Long-form runner. Usage: run_long_form.bat [full|synth|transcribe|short] +REM full synth + transcribe on the whole book (default) +REM synth synth only, no transcription (fast on GPU) +REM short synth + transcribe on a 65k-char slice (~chapters 1-7) +REM transcribe transcribe only, reuses the wav from a previous synth run +REM Double-click runs 'full'. Output streams to the window and to a log file. + +setlocal +set "MODE=%~1" +if "%MODE%"=="" set "MODE=full" + +set "EXTRA_ARGS=" +if /I "%MODE%"=="full" goto :full +if /I "%MODE%"=="synth" goto :synth +if /I "%MODE%"=="transcribe" goto :transcribe +if /I "%MODE%"=="short" goto :short +echo Unknown mode: %MODE% +echo Usage: %~nx0 [full^|synth^|transcribe^|short] +exit /b 2 + +:full +set "TAG=journey_all_full" +goto :run +:synth +set "TAG=journey_all_synth" +set "EXTRA_ARGS=--synth-only" +goto :run +:transcribe +set "TAG=journey_all_transcribe" +set "EXTRA_ARGS=--transcribe-only" +goto :run +:short +set "TAG=journey_short_full" +set "LONGFORM_CHARS=65000" +goto :run + +:run +set "SCRIPT_DIR=%~dp0" +set "REPO_ROOT=%SCRIPT_DIR%..\..\.." +set "LOG_DIR=%SCRIPT_DIR%output_long_form" +set "LOG_FILE=%LOG_DIR%\%TAG%.log" +set "LONGFORM_INPUT=examples/assorted_checks/test_transcription/input/journey_all.txt.gz" + +if not exist "%LOG_DIR%" mkdir "%LOG_DIR%" + +pushd "%REPO_ROOT%" +powershell -NoProfile -ExecutionPolicy Bypass -Command ^ + "Write-Output ('Run started ' + (Get-Date)) | Tee-Object -FilePath '%LOG_FILE%';" ^ + "uv run --project examples python examples/assorted_checks/test_transcription/test_long_form.py %EXTRA_ARGS% 2>&1 | Tee-Object -FilePath '%LOG_FILE%' -Append;" ^ + "Write-Output ('Run finished ' + (Get-Date)) | Tee-Object -FilePath '%LOG_FILE%' -Append;" +set RC=%ERRORLEVEL% +popd + +echo. +echo === DONE (mode=%MODE%, exit %RC%) -- log: %LOG_FILE% +pause diff --git a/examples/assorted_checks/test_transcription/test_long_form.py b/examples/assorted_checks/test_transcription/test_long_form.py new file mode 100644 index 00000000..e73ffc27 --- /dev/null +++ b/examples/assorted_checks/test_transcription/test_long_form.py @@ -0,0 +1,311 @@ +"""Long-form roundtrip baseline. + +Synthesizes a long input file with one voice via a running Kokoro server, +transcribes the result with faster-whisper, and reports timing + WER. Goal: +establish a baseline for "can the server produce 30-60 min of clean audio +in one go, and how long does it take" — not to assert pass/fail. + +Usage: + uv sync --extra transcription + uv run python assorted_checks/test_transcription/test_long_form.py + +Env overrides: + KOKORO_BASE_URL default http://localhost:8880/v1 + LONGFORM_VOICE default af_heart + LONGFORM_INPUT default input/journey_all.txt.gz (relative to this script; .gz auto-decoded) + LONGFORM_CHARS default unset (full file); int caps cleaned input length + WHISPER_MODEL default base.en +""" + +from __future__ import annotations + +import argparse +import gzip +import json +import os +import re +import sys +import time +from pathlib import Path + +import openai + + +BASE_URL = os.environ.get("KOKORO_BASE_URL", "http://localhost:8880/v1") +VOICE = os.environ.get("LONGFORM_VOICE", "af_heart") +WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "base.en") + +SCRIPT_DIR = Path(__file__).parent +INPUT_PATH = Path(os.environ.get("LONGFORM_INPUT", SCRIPT_DIR / "input" / "journey_all.txt.gz")) +OUTPUT_DIR = SCRIPT_DIR / "output_long_form" + + +def rel(path: Path) -> str: + """Path relative to the script dir, posix-style — keeps reports portable.""" + return path.resolve().relative_to(SCRIPT_DIR.resolve()).as_posix() + + +def _build_normalizer(): + # Imported lazily so --synth-only runs don't require faster-whisper/jiwer. + from jiwer.transforms import ( + Compose, + ReduceToListOfListOfWords, + RemoveMultipleSpaces, + RemovePunctuation, + Strip, + SubstituteWords, + ToLowerCase, + ) + + digit_words = { + "0": "zero", "1": "one", "2": "two", "3": "three", "4": "four", + "5": "five", "6": "six", "7": "seven", "8": "eight", "9": "nine", + "10": "ten", "11": "eleven", "12": "twelve", + } + return Compose([ + ToLowerCase(), + RemovePunctuation(), + SubstituteWords(digit_words), + RemoveMultipleSpaces(), + Strip(), + ReduceToListOfListOfWords(), + ]) + + +def clean_input(raw: str) -> str: + # Strip HTML/XML-style tags ("i.e." etc.) and collapse whitespace. + no_tags = re.sub(r"<[^>]+>", "", raw) + return re.sub(r"\s+", " ", no_tags).strip() + + +def fmt_time(seconds: float) -> str: + if seconds < 60: + return f"{seconds:.1f}s" + m, s = divmod(int(seconds), 60) + return f"{m}m{s:02d}s" + + +def synthesize_streaming(client: openai.OpenAI, text: str, out_path: Path) -> float: + start = time.perf_counter() + with client.audio.speech.with_streaming_response.create( + model="tts-1", + voice=VOICE, + input=text, + response_format="wav", + ) as response: + with open(out_path, "wb") as f: + for chunk in response.iter_bytes(chunk_size=64 * 1024): + f.write(chunk) + fix_streaming_wav_header(out_path) + return time.perf_counter() - start + + +def fix_streaming_wav_header(wav_path: Path) -> None: + # Streaming WAV responses stamp placeholder sizes (RIFF=0xFFFFFFFF, data + # chunk size near 0) because the server doesn't know the total length up + # front. Rewrite both fields in place so downstream tools see real sizes. + import struct + + size = wav_path.stat().st_size + with open(wav_path, "r+b") as f: + header = f.read(12) + if len(header) < 12 or header[:4] != b"RIFF" or header[8:12] != b"WAVE": + return # not a RIFF/WAVE file, leave it alone + + # Walk subchunks until we find 'data', record its size offset. + data_size_offset = None + while True: + chunk_header = f.read(8) + if len(chunk_header) < 8: + break + chunk_id = chunk_header[:4] + chunk_size = struct.unpack(" float: + import wave + + with wave.open(str(wav_path), "rb") as wf: + return wf.getnframes() / float(wf.getframerate()) + + +def transcribe(model, audio_path: Path) -> tuple[str, float]: + start = time.perf_counter() + segments, _info = model.transcribe( + str(audio_path), + beam_size=1, + vad_filter=True, + ) + text = " ".join(seg.text for seg in segments).strip() + return text, time.perf_counter() - start + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--synth-only", + action="store_true", + help="Generate audio and stop. Skip Whisper load and transcription.", + ) + parser.add_argument( + "--transcribe-only", + action="store_true", + help="Skip synthesis and transcribe the existing wav from a previous run.", + ) + parser.add_argument( + "--chars", + type=int, + default=None, + help="Cap cleaned input at N chars (cuts at the last whitespace before N). " + "Default: full file. Also reads LONGFORM_CHARS.", + ) + args = parser.parse_args() + if args.synth_only and args.transcribe_only: + parser.error("--synth-only and --transcribe-only are mutually exclusive") + + char_cap = args.chars + if char_cap is None and os.environ.get("LONGFORM_CHARS"): + char_cap = int(os.environ["LONGFORM_CHARS"]) + + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + if INPUT_PATH.suffix == ".gz": + with gzip.open(INPUT_PATH, "rt", encoding="utf-8") as f: + raw = f.read() + else: + raw = INPUT_PATH.read_text(encoding="utf-8") + text = clean_input(raw) + full_chars = len(text) + if char_cap is not None and char_cap < full_chars: + cut = text.rfind(" ", 0, char_cap) + text = text[: cut if cut > 0 else char_cap] + word_count = len(text.split()) + char_count = len(text) + + print(f"Server: {BASE_URL}") + print(f"Voice: {VOICE}") + if char_cap is not None and char_count < full_chars: + print(f"Input: {INPUT_PATH.name} ({word_count} words, {char_count} chars; capped at {char_cap} of {full_chars})") + else: + print(f"Input: {INPUT_PATH.name} ({word_count} words, {char_count} chars)") + print(f"Whisper: {WHISPER_MODEL} (CPU, int8, VAD on)") + print() + + audio_path = OUTPUT_DIR / f"long_form_{VOICE}.wav" + + if args.transcribe_only: + if not audio_path.exists(): + print(f"--transcribe-only set but no audio at {audio_path}") + return 2 + synth_s = 0.0 + audio_s = audio_duration_seconds(audio_path) + synth_rtf = 0.0 + file_mb = audio_path.stat().st_size / (1024 * 1024) + print(f"Reusing existing audio ({fmt_time(audio_s)}, {file_mb:.1f} MB)") + print() + else: + client = openai.OpenAI(base_url=BASE_URL, api_key="not-needed", timeout=None) + + print("Synthesizing...") + try: + synth_s = synthesize_streaming(client, text, audio_path) + except Exception as exc: + print(f" synthesis failed after {time.perf_counter():.1f}s: {exc}") + return 2 + + audio_s = audio_duration_seconds(audio_path) + synth_rtf = audio_s / synth_s if synth_s > 0 else 0.0 + file_mb = audio_path.stat().st_size / (1024 * 1024) + print(f" synth time: {fmt_time(synth_s)}") + print(f" audio length: {fmt_time(audio_s)}") + print(f" synth speedup: {synth_rtf:.2f}x realtime") + print(f" file size: {file_mb:.1f} MB ({rel(audio_path)})") + print() + + report = { + "voice": VOICE, + "input_file": rel(INPUT_PATH), + "input_words": word_count, + "input_chars": char_count, + "input_chars_cap": char_cap, + "audio_file": rel(audio_path), + "audio_seconds": round(audio_s, 2), + "synth_seconds": round(synth_s, 2), + "synth_realtime_factor": round(synth_rtf, 2), + "audio_file_mb": round(file_mb, 2), + } + + if args.synth_only: + report_path = OUTPUT_DIR / "long_form_report.json" + report_path.write_text(json.dumps(report, indent=2)) + print("Summary (synth-only)") + print(f" generated {fmt_time(audio_s)} of audio in {fmt_time(synth_s)} ({synth_rtf:.1f}x rt)") + print(f" audio: {rel(audio_path)}") + print(f" report: {rel(report_path)}") + return 0 + + from faster_whisper import WhisperModel + from jiwer import wer + + normalize = _build_normalizer() + + print("Loading Whisper model...") + model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8") + + print("Transcribing (this can take a while on CPU)...") + transcript, trans_s = transcribe(model, audio_path) + trans_rtf = audio_s / trans_s if trans_s > 0 else 0.0 + print(f" transcribe time: {fmt_time(trans_s)}") + print(f" transcribe speedup: {trans_rtf:.2f}x realtime") + print(f" transcript words: {len(transcript.split())}") + print() + + score = wer( + text, + transcript, + reference_transform=normalize, + hypothesis_transform=normalize, + ) + print(f"WER (normalized): {score:.4f}") + print() + + transcript_path = OUTPUT_DIR / f"long_form_{VOICE}.transcript.txt" + transcript_path.write_text(transcript, encoding="utf-8") + + report.update({ + "whisper_model": WHISPER_MODEL, + "transcribe_seconds": round(trans_s, 2), + "transcribe_realtime_factor": round(trans_rtf, 2), + "transcript_words": len(transcript.split()), + "wer": round(score, 4), + "transcript_file": rel(transcript_path), + }) + report_path = OUTPUT_DIR / "long_form_report.json" + report_path.write_text(json.dumps(report, indent=2)) + + print("Summary") + print(f" generated {fmt_time(audio_s)} of audio in {fmt_time(synth_s)} ({synth_rtf:.1f}x rt)") + print(f" transcribed in {fmt_time(trans_s)} ({trans_rtf:.1f}x rt)") + print(f" WER {score:.4f} vs cleaned input") + print(f" report: {rel(report_path)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/assorted_checks/test_transcription/test_transcription.py b/examples/assorted_checks/test_transcription/test_transcription.py new file mode 100644 index 00000000..e40d101e --- /dev/null +++ b/examples/assorted_checks/test_transcription/test_transcription.py @@ -0,0 +1,175 @@ +"""TTS roundtrip validation. + +Synthesizes short phrases via a running Kokoro server, transcribes the audio +locally with faster-whisper, and reports word error rate against the expected +text. Intended as a manual sanity check, not a pytest suite. + +Usage: + uv sync --extra transcription + uv run python assorted_checks/test_transcription/test_transcription.py + +Env overrides: + KOKORO_BASE_URL default http://localhost:8880/v1 + WHISPER_MODEL default base.en + WER_THRESHOLD default 0.2 +""" + +from __future__ import annotations + +import json +import os +import sys +import time +from dataclasses import asdict, dataclass +from pathlib import Path + +import openai +from faster_whisper import WhisperModel +from jiwer import wer +from jiwer.transforms import ( + Compose, + RemoveMultipleSpaces, + RemovePunctuation, + ReduceToListOfListOfWords, + Strip, + SubstituteWords, + ToLowerCase, +) + + +# Whisper often writes small numbers as digits ("5") even when the prompt was +# spelled out ("five"). Normalize both directions before computing WER so the +# metric reflects pronunciation accuracy, not formatting. +_DIGIT_WORDS = { + "0": "zero", "1": "one", "2": "two", "3": "three", "4": "four", + "5": "five", "6": "six", "7": "seven", "8": "eight", "9": "nine", + "10": "ten", "11": "eleven", "12": "twelve", +} + +_NORMALIZE = Compose([ + ToLowerCase(), + RemovePunctuation(), + SubstituteWords(_DIGIT_WORDS), + RemoveMultipleSpaces(), + Strip(), + ReduceToListOfListOfWords(), +]) + + +def _normalized_wer(reference: str, hypothesis: str) -> float: + return wer( + reference, + hypothesis, + reference_transform=_NORMALIZE, + hypothesis_transform=_NORMALIZE, + ) + + +BASE_URL = os.environ.get("KOKORO_BASE_URL", "http://localhost:8880/v1") +WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "base.en") +WER_THRESHOLD = float(os.environ.get("WER_THRESHOLD", "0.2")) + +SCRIPT_DIR = Path(__file__).parent +OUTPUT_DIR = SCRIPT_DIR / "output" + + +def rel(path: Path) -> str: + """Path relative to the script dir, posix-style — keeps reports portable.""" + return path.resolve().relative_to(SCRIPT_DIR.resolve()).as_posix() + +CASES: list[tuple[str, str]] = [ + ("af_heart", "The quick brown fox jumps over the lazy dog."), + ("af_bella", "She sells seashells by the seashore."), + ("am_adam", "Pack my box with five dozen liquor jugs."), + ("af_nicole", "The five boxing wizards jump quickly across the field."), +] + + +@dataclass +class Result: + voice: str + expected: str + transcript: str + wer: float + passed: bool + audio_path: str + synth_seconds: float + transcribe_seconds: float + + +def synthesize(client: openai.OpenAI, voice: str, text: str, out_path: Path) -> float: + start = time.perf_counter() + response = client.audio.speech.create( + model="tts-1", + voice=voice, + input=text, + response_format="wav", + ) + out_path.write_bytes(response.content) + return time.perf_counter() - start + + +def transcribe(model: WhisperModel, audio_path: Path) -> tuple[str, float]: + start = time.perf_counter() + segments, _info = model.transcribe(str(audio_path), beam_size=1) + text = " ".join(seg.text for seg in segments).strip() + return text, time.perf_counter() - start + + +def main() -> int: + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + print(f"Server: {BASE_URL}") + print(f"Whisper: {WHISPER_MODEL} (CPU, int8)") + print(f"Threshold: WER < {WER_THRESHOLD}") + print() + + client = openai.OpenAI(base_url=BASE_URL, api_key="not-needed", timeout=60) + + print("Loading Whisper model (first run downloads ~150MB)...") + model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8") + print("Loaded.\n") + + results: list[Result] = [] + for voice, expected in CASES: + audio_path = OUTPUT_DIR / f"{voice}.wav" + print(f"[{voice}] {expected!r}") + try: + synth_s = synthesize(client, voice, expected, audio_path) + except Exception as exc: + print(f" synthesis failed: {exc}\n") + continue + + transcript, trans_s = transcribe(model, audio_path) + score = _normalized_wer(expected, transcript) + passed = score < WER_THRESHOLD + + print(f" heard: {transcript!r}") + print(f" WER: {score:.3f} ({'PASS' if passed else 'FAIL'})") + print(f" timing: synth {synth_s:.2f}s, transcribe {trans_s:.2f}s\n") + + results.append( + Result( + voice=voice, + expected=expected, + transcript=transcript, + wer=score, + passed=passed, + audio_path=rel(audio_path), + synth_seconds=synth_s, + transcribe_seconds=trans_s, + ) + ) + + report_path = OUTPUT_DIR / "report.json" + report_path.write_text(json.dumps([asdict(r) for r in results], indent=2)) + + total = len(results) + passed_count = sum(1 for r in results if r.passed) + print(f"Summary: {passed_count}/{total} passed. Report: {rel(report_path)}") + + return 0 if results and passed_count == total else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/assorted_checks/validate_wav.py b/examples/assorted_checks/validate_wav.py index 844655ae..a2c81e2c 100644 --- a/examples/assorted_checks/validate_wav.py +++ b/examples/assorted_checks/validate_wav.py @@ -248,7 +248,7 @@ def generate_analysis_plots( if __name__ == "__main__": - wav_file = r"C:\Users\jerem\Desktop\Kokoro-FastAPI\examples\assorted_checks\benchmarks\output_audio\chunk_600_tokens.wav" + wav_file = str(Path(__file__).parent / "benchmarks" / "output_audio" / "chunk_600_tokens.wav") silent = False print(f"\n\n Processing:\n\t{wav_file}") diff --git a/examples/pyproject.toml b/examples/pyproject.toml new file mode 100644 index 00000000..4271776a --- /dev/null +++ b/examples/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "kokoro-examples" +version = "0.1.0" +description = "Standalone scripts and validation tools for Kokoro-FastAPI." +requires-python = ">=3.10" +dependencies = [ + "openai>=1.0.0", + "numpy>=1.24", + "scipy>=1.10", + "loguru>=0.7", + "tqdm>=4.65", +] + +[project.optional-dependencies] +playback = [ + "pyaudio>=0.2.13", +] +benchmarks = [ + "matplotlib>=3.7", + "pandas>=2.0", + "psutil>=5.9", + "tiktoken>=0.5", +] +transcription = [ + "faster-whisper>=1.0.0", + "jiwer>=3.0.0", +] + +[tool.uv] +package = false From 0c50b03ed647188bebf97ce22371536678cb35fe Mon Sep 17 00:00:00 2001 From: remsky Date: Mon, 11 May 2026 01:26:41 -0600 Subject: [PATCH 11/45] build(docker): add multi-stage for ~45% gpu image trim, align gpu base image with cu126 wheels, compose image naming, add INCLUDE_JAPANESE flag for future build opts build(bake): redirected to *.optimized dockerfiles examples(transcription tests): long form and multilingual whisper transcription reporsts w/ WER scoring thresholds chore: release alignment prep, version WIP bumped to 0.3.0-rc --- .gitignore | 10 +- README.md | 2 +- VERSION | 2 +- docker-bake.hcl | 4 +- docker/cpu/Dockerfile | 8 + docker/cpu/Dockerfile.optimized | 84 ++++++ docker/cpu/docker-compose.yml | 2 +- docker/gpu/Dockerfile | 10 +- docker/gpu/Dockerfile.optimized | 88 +++++++ docker/gpu/docker-compose.yml | 4 +- docker/rocm/Dockerfile | 8 + docker/rocm/docker-compose.yml | 1 + .../test_transcription/BASELINE.md | 48 ++-- .../test_transcription/README.md | 38 ++- .../test_transcription/output/report.json | 16 +- .../output_long_form/long_form_report.json | 16 +- .../output_multilingual/report.json | 155 +++++++++++ .../report.json | 155 +++++++++++ .../output_multilingual_cpu_noja/report.json | 155 +++++++++++ .../test_transcription/run_long_form.bat | 15 +- .../test_transcription/test_long_form.py | 84 ++++-- .../test_transcription/test_transcription.py | 8 +- .../test_transcription_multilingual.py | 244 ++++++++++++++++++ examples/pyproject.toml | 5 + 24 files changed, 1072 insertions(+), 90 deletions(-) create mode 100644 docker/cpu/Dockerfile.optimized create mode 100644 docker/gpu/Dockerfile.optimized create mode 100644 examples/assorted_checks/test_transcription/output_multilingual/report.json create mode 100644 examples/assorted_checks/test_transcription/output_multilingual_cpu_default/report.json create mode 100644 examples/assorted_checks/test_transcription/output_multilingual_cpu_noja/report.json create mode 100644 examples/assorted_checks/test_transcription/test_transcription_multilingual.py diff --git a/.gitignore b/.gitignore index b5c84c65..211f7d4f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,8 @@ ENV/ # Other project files .env +.claude/* +.agents/* Kokoro-82M/ ui/data/ EXTERNAL_UV_DOCUMENTATION* @@ -49,7 +51,7 @@ app api/temp_files/ # Docker -Dockerfile* +# Dockerfile* docker-compose* examples/ebook_test/chapter_to_audio.py examples/ebook_test/chapters_to_audio.py @@ -73,9 +75,11 @@ examples/assorted_checks/test_transcription/output/*.wav examples/assorted_checks/test_transcription/output_long_form/*.log examples/assorted_checks/test_transcription/output_long_form/*.transcript.txt examples/assorted_checks/test_transcription/output_long_form/*.wav +examples/assorted_checks/test_transcription/output_long_form/*.synth_meta.json +examples/assorted_checks/test_transcription/output_multilingual/*.wav uv.lock # Mac MPS virtualenv for dual testing .venv-mps -.claude/block-remote.ps1 -.claude/settings.local.json +examples/assorted_checks/test_transcription/*/*.wav +pyproject.toml.bkp diff --git a/README.md b/README.md index 60a30c40..12b18849 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,7 @@ Key Performance Metrics:

GPU Vs. CPU ```bash -# GPU: Requires NVIDIA GPU with CUDA 12.8 support (~35x-100x realtime speed) +# GPU: Requires NVIDIA GPU with CUDA 12.6 support (~35x-100x realtime speed) cd docker/gpu docker compose up --build diff --git a/VERSION b/VERSION index 72f9fa82..32513898 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.4 \ No newline at end of file +0.3.0-rc \ No newline at end of file diff --git a/docker-bake.hcl b/docker-bake.hcl index 89174aec..9db0df76 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -31,13 +31,13 @@ target "_common" { # Base settings for CPU builds target "_cpu_base" { inherits = ["_common"] - dockerfile = "docker/cpu/Dockerfile" + dockerfile = "docker/cpu/Dockerfile.optimized" } # Base settings for GPU builds target "_gpu_base" { inherits = ["_common"] - dockerfile = "docker/gpu/Dockerfile" + dockerfile = "docker/gpu/Dockerfile.optimized" } # CPU target with multi-platform support diff --git a/docker/cpu/Dockerfile b/docker/cpu/Dockerfile index b004d7a0..28d3ffc1 100644 --- a/docker/cpu/Dockerfile +++ b/docker/cpu/Dockerfile @@ -32,6 +32,14 @@ COPY --chown=appuser:appuser pyproject.toml ./pyproject.toml RUN uv venv --python 3.10 && \ uv sync --extra cpu --no-cache +# Japanese support requires the UniDic dictionary (~526MB on disk) for fugashi/MeCab. +# Enabled by default; set --build-arg INCLUDE_JAPANESE=false to skip and shave the image. +# Keep true. Setting false silently breaks ja generation. +ARG INCLUDE_JAPANESE=true +RUN if [ "$INCLUDE_JAPANESE" = "true" ]; then \ + .venv/bin/python -m unidic download; \ + fi + # Copy project files including models COPY --chown=appuser:appuser api ./api COPY --chown=appuser:appuser web ./web diff --git a/docker/cpu/Dockerfile.optimized b/docker/cpu/Dockerfile.optimized new file mode 100644 index 00000000..2d401434 --- /dev/null +++ b/docker/cpu/Dockerfile.optimized @@ -0,0 +1,84 @@ +# Stage 1: Builder +FROM python:3.10 as builder + +# Install build dependencies +RUN apt-get update -y && \ + apt-get install -y git curl g++ cmake make && \ + curl -LsSf https://astral.sh/uv/install.sh | sh && \ + mv /root/.local/bin/uv /usr/local/bin/ && \ + mv /root/.local/bin/uvx /usr/local/bin/ + +# Install Rust for building sudachipy and pyopenjtalk +RUN curl https://sh.rustup.rs -sSf | sh -s -- -y +ENV PATH="/root/.cargo/bin:$PATH" + +WORKDIR /app + +# Copy dependency files +COPY pyproject.toml ./pyproject.toml + +# Install dependencies with CPU extras +ENV UV_HTTP_TIMEOUT=120 UV_HTTP_RETRIES=3 + +RUN uv venv --python 3.10 && \ + uv sync --extra cpu --no-cache --no-install-project + +# Stage 2: Runtime - slim image +FROM python:3.10-slim + +# Install runtime dependencies + uv (needed by entrypoint.sh) +RUN apt-get update -y && \ + apt-get install -y espeak-ng espeak-ng-data libsndfile1 ffmpeg curl && \ + curl -LsSf https://astral.sh/uv/install.sh | sh && \ + mv /root/.local/bin/uv /usr/local/bin/ && \ + mv /root/.local/bin/uvx /usr/local/bin/ && \ + apt-get clean && rm -rf /var/lib/apt/lists/* && \ + mkdir -p /usr/share/espeak-ng-data && \ + ln -s /usr/lib/*/espeak-ng-data/* /usr/share/espeak-ng-data/ && \ + useradd -m -u 1000 appuser && \ + mkdir -p /app/api/src/models/v1_0 && \ + chown -R appuser:appuser /app + +WORKDIR /app + +# Copy virtual environment from builder +COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv + +# Download model in runtime stage so download_model.py is present for runtime re-downloads via entrypoint +COPY --chown=appuser:appuser docker/scripts/download_model.py ./download_model.py +ARG DOWNLOAD_MODEL=true +RUN if [ "$DOWNLOAD_MODEL" = "true" ]; then \ + /app/.venv/bin/python download_model.py --output api/src/models/v1_0 && \ + chown -R appuser:appuser /app/api/src/models; \ + fi + +# Japanese support requires the UniDic dictionary (~526MB on disk) for fugashi/MeCab. +# Enabled by default; set --build-arg INCLUDE_JAPANESE=false to skip and shave the image. +# Keep true. Setting false silently breaks ja generation. +ARG INCLUDE_JAPANESE=true +RUN if [ "$INCLUDE_JAPANESE" = "true" ]; then \ + /app/.venv/bin/python -m unidic download && \ + chown -R appuser:appuser /app/.venv/lib/python*/site-packages/unidic; \ + fi + +# Copy project files +COPY --chown=appuser:appuser api ./api +COPY --chown=appuser:appuser web ./web +COPY --chown=appuser:appuser docker/scripts/entrypoint.sh ./entrypoint.sh +RUN chmod +x ./entrypoint.sh + +USER appuser + +# Set environment variables +ENV PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app:/app/api \ + PATH="/app/.venv/bin:$PATH" \ + UV_LINK_MODE=copy \ + USE_GPU=false \ + PHONEMIZER_ESPEAK_PATH=/usr/bin \ + PHONEMIZER_ESPEAK_DATA=/usr/share/espeak-ng-data \ + ESPEAK_DATA_PATH=/usr/share/espeak-ng-data \ + DEVICE="cpu" + +# Run FastAPI server +CMD ["./entrypoint.sh"] \ No newline at end of file diff --git a/docker/cpu/docker-compose.yml b/docker/cpu/docker-compose.yml index 7cb9141c..f2fd9c8f 100644 --- a/docker/cpu/docker-compose.yml +++ b/docker/cpu/docker-compose.yml @@ -3,7 +3,7 @@ services: kokoro-tts: build: context: ../.. - dockerfile: docker/cpu/Dockerfile + dockerfile: docker/cpu/Dockerfile.optimized volumes: - ../../api:/app/api ports: diff --git a/docker/gpu/Dockerfile b/docker/gpu/Dockerfile index 9083fa23..2ae50ca9 100644 --- a/docker/gpu/Dockerfile +++ b/docker/gpu/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM nvcr.io/nvidia/cuda:12.9.1-cudnn-devel-ubuntu24.04 +FROM --platform=$BUILDPLATFORM nvcr.io/nvidia/cuda:12.6.3-cudnn-devel-ubuntu24.04 # Install Python and other dependencies RUN apt-get update -y && \ @@ -23,6 +23,14 @@ COPY --chown=appuser:appuser pyproject.toml ./pyproject.toml RUN uv venv --python 3.10 && \ uv sync --extra gpu --no-cache +# Japanese support requires the UniDic dictionary (~526MB on disk) for fugashi/MeCab. +# Enabled by default; set --build-arg INCLUDE_JAPANESE=false to skip and shave the image. +# Keep true. Setting false silently breaks ja generation. +ARG INCLUDE_JAPANESE=true +RUN if [ "$INCLUDE_JAPANESE" = "true" ]; then \ + .venv/bin/python -m unidic download; \ + fi + # Copy project files including models COPY --chown=appuser:appuser api ./api COPY --chown=appuser:appuser web ./web diff --git a/docker/gpu/Dockerfile.optimized b/docker/gpu/Dockerfile.optimized new file mode 100644 index 00000000..8f953c38 --- /dev/null +++ b/docker/gpu/Dockerfile.optimized @@ -0,0 +1,88 @@ +# Stage 1: Builder - Use devel image for compilation +FROM --platform=$BUILDPLATFORM nvcr.io/nvidia/cuda:12.6.3-cudnn-devel-ubuntu24.04 AS builder + +# Install Python and build dependencies +RUN apt-get update -y && \ + apt-get install -y python3.10 python3-venv python3-dev git curl && \ + apt-get clean && rm -rf /var/lib/apt/lists/* && \ + curl -LsSf https://astral.sh/uv/install.sh | sh && \ + mv /root/.local/bin/uv /usr/local/bin/ && \ + mv /root/.local/bin/uvx /usr/local/bin/ + +WORKDIR /app + +# Copy dependency files +COPY pyproject.toml ./pyproject.toml + +# Install dependencies with GPU extras (--no-install-project since api/src doesn't exist yet) +# UV_PYTHON_INSTALL_DIR keeps the uv-managed interpreter at a stage-shareable path so the +# runtime stage can find the venv's python target via COPY --from=builder. +ENV UV_HTTP_TIMEOUT=120 UV_HTTP_RETRIES=3 \ + UV_PYTHON_INSTALL_DIR=/opt/uv-python + +RUN uv venv --python 3.10 && \ + uv sync --extra gpu --no-cache --no-install-project + +# Stage 2: Runtime - Use smaller runtime image +FROM --platform=$BUILDPLATFORM nvcr.io/nvidia/cuda:12.6.3-cudnn-runtime-ubuntu24.04 + +# Install runtime dependencies + uv (needed by entrypoint.sh) +RUN apt-get update -y && \ + apt-get install -y python3.10 espeak-ng espeak-ng-data libsndfile1 ffmpeg curl && \ + curl -LsSf https://astral.sh/uv/install.sh | sh && \ + mv /root/.local/bin/uv /usr/local/bin/ && \ + mv /root/.local/bin/uvx /usr/local/bin/ && \ + apt-get clean && rm -rf /var/lib/apt/lists/* && \ + mkdir -p /usr/share/espeak-ng-data && \ + ln -s /usr/lib/*/espeak-ng-data/* /usr/share/espeak-ng-data/ && \ + useradd -m -u 1001 appuser && \ + mkdir -p /app/api/src/models/v1_0 && \ + chown -R appuser:appuser /app + +WORKDIR /app + +# Copy uv-managed Python interpreter (the venv's bin/python symlinks into here) +COPY --from=builder /opt/uv-python /opt/uv-python + +# Copy virtual environment from builder +COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv + +# Download model in runtime stage so download_model.py is present for runtime re-downloads via entrypoint +COPY --chown=appuser:appuser docker/scripts/download_model.py ./download_model.py +ARG DOWNLOAD_MODEL=true +RUN if [ "$DOWNLOAD_MODEL" = "true" ]; then \ + /app/.venv/bin/python download_model.py --output api/src/models/v1_0 && \ + chown -R appuser:appuser /app/api/src/models; \ + fi + +# Japanese support requires the UniDic dictionary (~526MB on disk) for fugashi/MeCab. +# Enabled by default; set --build-arg INCLUDE_JAPANESE=false to skip and shave the image. +# Keep true. Setting false silently breaks ja generation. +ARG INCLUDE_JAPANESE=true +RUN if [ "$INCLUDE_JAPANESE" = "true" ]; then \ + /app/.venv/bin/python -m unidic download && \ + chown -R appuser:appuser /app/.venv/lib/python*/site-packages/unidic; \ + fi + +# Copy project files +COPY --chown=appuser:appuser pyproject.toml ./pyproject.toml +COPY --chown=appuser:appuser api ./api +COPY --chown=appuser:appuser web ./web +COPY --chown=appuser:appuser docker/scripts/entrypoint.sh ./entrypoint.sh +RUN chmod +x ./entrypoint.sh + +USER appuser + +# Set environment variables +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app:/app/api \ + UV_LINK_MODE=copy \ + USE_GPU=true \ + PHONEMIZER_ESPEAK_PATH=/usr/bin \ + PHONEMIZER_ESPEAK_DATA=/usr/share/espeak-ng-data \ + ESPEAK_DATA_PATH=/usr/share/espeak-ng-data \ + DEVICE="gpu" + +# Run FastAPI server +CMD ["./entrypoint.sh"] \ No newline at end of file diff --git a/docker/gpu/docker-compose.yml b/docker/gpu/docker-compose.yml index 17d6484c..d01a62e6 100644 --- a/docker/gpu/docker-compose.yml +++ b/docker/gpu/docker-compose.yml @@ -1,10 +1,10 @@ -name: kokoro-tts-gpu +name: kokoro-fastapi-gpu services: kokoro-tts: # image: ghcr.io/remsky/kokoro-fastapi-gpu:v${VERSION} build: context: ../.. - dockerfile: docker/gpu/Dockerfile + dockerfile: docker/gpu/Dockerfile.optimized volumes: - ../../api:/app/api user: "1001:1001" # Ensure container runs as UID 1001 (appuser) diff --git a/docker/rocm/Dockerfile b/docker/rocm/Dockerfile index 9b0d19fa..43c6f005 100644 --- a/docker/rocm/Dockerfile +++ b/docker/rocm/Dockerfile @@ -49,6 +49,14 @@ RUN --mount=type=cache,target=/root/.cache/uv \ uv venv --python 3.12 && \ uv sync --extra rocm +# Japanese support requires the UniDic dictionary (~526MB on disk) for fugashi/MeCab. +# Enabled by default; set --build-arg INCLUDE_JAPANESE=false to skip and shave the image. +# Keep true. Setting false silently breaks ja generation. +ARG INCLUDE_JAPANESE=true +RUN if [ "$INCLUDE_JAPANESE" = "true" ]; then \ + .venv/bin/python -m unidic download; \ + fi + # Run kdb files (shape files for MIOpen) ENV ROCM_VERSION=6.4.4 COPY --chown=appuser:appuser docker/rocm/kdb_install.sh /tmp/ diff --git a/docker/rocm/docker-compose.yml b/docker/rocm/docker-compose.yml index 8a9fc731..b569a8ad 100644 --- a/docker/rocm/docker-compose.yml +++ b/docker/rocm/docker-compose.yml @@ -1,3 +1,4 @@ +name: kokoro-fastapi-rocm services: kokoro-tts: build: diff --git a/examples/assorted_checks/test_transcription/BASELINE.md b/examples/assorted_checks/test_transcription/BASELINE.md index 12d46508..69d0d32a 100644 --- a/examples/assorted_checks/test_transcription/BASELINE.md +++ b/examples/assorted_checks/test_transcription/BASELINE.md @@ -1,37 +1,37 @@ # Long-Form Baseline -Reference numbers from `test_long_form.py` against a known-good build, so future -runs have something to compare against. - -## Setup +Reference numbers from `test_long_form.py` so future runs have something to +compare against. All numbers are with `WHISPER_DEVICE=cuda` (the .bat default). - Voice: `af_heart` - Server: local, GPU -- Whisper: `base.en`, CPU, int8, VAD on -- Output format: 24 kHz mono PCM WAV -- Source: `input/journey_all.txt.gz` — Project Gutenberg, *A Journey to the Centre of the Earth* (Jules Verne) - -Both runs use the same source. The short run caps the cleaned input at 65,000 chars (`--chars 65000`), which lands at roughly end-of-chapter-7. The full run uses the entire book. - -## Numbers +- Whisper: `base.en`, float16, VAD on, CUDA +- Source: `input/journey_all.txt.gz` (*A Journey to the Centre of the Earth*, Project Gutenberg) +- Short: `--chars 65000` (~end of chapter 7). Full: entire book. -| Metric | Short (`--chars 65000`) | Full book | +| Metric | Short (warm) | Full | | --- | --- | --- | | Input words / chars | 11,468 / 64,996 | 88,884 / 502,766 | -| Synth time | 1m21s | 10m13s | | Audio length | 66m06s | 507m52s | -| Synth speedup | 48.6× realtime | 49.7× realtime | +| Synth time / speedup | 1m49s, 36.4x rt | 11m06s, 45.7x rt | +| Transcribe time / speedup | 1m03s, 62.4x rt | 7m48s, 65.1x rt | | Output size | 181.6 MB | 1394.9 MB | -| Transcribe time | 4m54s | 31m24s | -| Transcribe speedup | 13.5× realtime | 16.2× realtime | -| Transcript words | 11,546 | 89,300 | -| **WER (normalized)** | **0.0481** | **0.0339** | +| Transcript words | 11,518 | 89,173 | +| **WER (normalized)** | **0.0466** | **0.0334** | + +Captured on cu126 GPU build, warm container. + +## Cold vs warm -## Regression thresholds +The short test is sensitive to first-run cuDNN autotune: a cold-container short +synth on the same setup landed at 17x rt (vs 36x warm). The full run is long +enough to amortize the autotune cost, so it lands at the same number cold or +warm. When comparing the short numbers, make sure the container has done at +least one prior synth — otherwise you'll be measuring autotune, not throughput. -A fresh run on the same input + voice + Whisper config should land near the table above. Loose bands: +## Regression bands -- WER < 0.06 (current: 0.034–0.048) -- Transcript word count within **±1%** of cleaned input -- Synth realtime factor in the 40–60× range (GPU-dependent; a 10× regression is worth a look) -- Transcribe realtime factor in the 13–17× range (CPU-dependent on `base.en` int8) +- WER < 0.07 +- Transcript word count within +/-1% of cleaned input +- Synth >= 25x realtime, warm (depends on GPU; cold first-run on short can drop to ~15-20x) +- Transcribe >= 40x realtime on CUDA (CPU `base.en` int8 lands ~13-17x) diff --git a/examples/assorted_checks/test_transcription/README.md b/examples/assorted_checks/test_transcription/README.md index dbb5eed1..24350d5c 100644 --- a/examples/assorted_checks/test_transcription/README.md +++ b/examples/assorted_checks/test_transcription/README.md @@ -1,19 +1,30 @@ # Transcription Roundtrip Check -Synthesizes a few phrases with a running Kokoro server, transcribes the audio -locally with [`faster-whisper`](https://github.com/SYSTRAN/faster-whisper), -and reports word error rate (WER) against the expected text. +Synthesizes phrases with a running Kokoro server, transcribes the audio with +[`faster-whisper`](https://github.com/SYSTRAN/faster-whisper), and reports WER +against the expected text. + +- `test_transcription.py`: short English clips per voice, fast sanity check + (uses `base.en`, WER). +- `test_transcription_multilingual.py`: one short clip per non-English voice + with native reference text. Defaults to multilingual `small` (~470MB), uses + CER for ja/zh and WER otherwise. Output goes to `output_multilingual/`. +- `test_long_form.py`: multi-hour roundtrip on a full book. See `BASELINE.md`. + Windows wrapper: `run_long_form.bat [short|full|synth|transcribe]` + (default `short`). ## Run -From the `examples/` directory: +From the repo root: ```bash -uv sync --extra transcription -uv run python assorted_checks/test_transcription/test_transcription.py +uv sync --project examples --extra transcription --extra transcription-gpu +uv run --project examples python examples/assorted_checks/test_transcription/test_transcription.py ``` -First run downloads the Whisper model (~150 MB for `base.en`) into the HF cache. +Omit `--extra transcription-gpu` to skip the ~1.2 GB cuDNN/cuBLAS download and +run Whisper on CPU. First run also pulls the `base.en` model (~150 MB) into +the HF cache. ## Config (env vars) @@ -21,9 +32,16 @@ First run downloads the Whisper model (~150 MB for `base.en`) into the HF cache. | --- | --- | --- | | `KOKORO_BASE_URL` | `http://localhost:8880/v1` | Running Kokoro server | | `WHISPER_MODEL` | `base.en` | Try `tiny.en` for speed, `small.en` for accuracy | -| `WER_THRESHOLD` | `0.2` | Per-clip pass cutoff | +| `WHISPER_DEVICE` | `cpu` (script) / `cuda` (bat) | Whisper device | +| `WHISPER_COMPUTE` | `int8` on cpu, `float16` on cuda | CTranslate2 compute type | +| `WER_THRESHOLD` | `0.2` | Per-clip pass cutoff (short test only) | + +`test_long_form.py` also reads `LONGFORM_VOICE`, `LONGFORM_INPUT`, and +`LONGFORM_CHARS`; see its module docstring. ## Output -WAVs and a `report.json` are written to `output/`. Exit code is `0` if every -case passed. +- Short test → `output/` (WAVs + `report.json`). +- Long-form → `output_long_form/` (WAV, transcript, `long_form_report.json`, + and a `*.synth_meta.json` sidecar so `transcribe`-only runs inherit the + exact char cap from the prior synth). diff --git a/examples/assorted_checks/test_transcription/output/report.json b/examples/assorted_checks/test_transcription/output/report.json index 9ec77ed7..a585ec0f 100644 --- a/examples/assorted_checks/test_transcription/output/report.json +++ b/examples/assorted_checks/test_transcription/output/report.json @@ -6,8 +6,8 @@ "wer": 0.0, "passed": true, "audio_path": "output/af_heart.wav", - "synth_seconds": 2.286747300007846, - "transcribe_seconds": 1.1613717000000179 + "synth_seconds": 1.838453500000469, + "transcribe_seconds": 0.8427800000026764 }, { "voice": "af_bella", @@ -16,8 +16,8 @@ "wer": 0.0, "passed": true, "audio_path": "output/af_bella.wav", - "synth_seconds": 1.3922755000094185, - "transcribe_seconds": 1.0665276000072481 + "synth_seconds": 1.1176961999990453, + "transcribe_seconds": 0.8100260000028356 }, { "voice": "am_adam", @@ -26,8 +26,8 @@ "wer": 0.0, "passed": true, "audio_path": "output/am_adam.wav", - "synth_seconds": 1.4518334999884246, - "transcribe_seconds": 0.9967074000014691 + "synth_seconds": 1.2620734999982233, + "transcribe_seconds": 0.8112173000008625 }, { "voice": "af_nicole", @@ -36,7 +36,7 @@ "wer": 0.0, "passed": true, "audio_path": "output/af_nicole.wav", - "synth_seconds": 2.4623890000075335, - "transcribe_seconds": 0.9693172000115737 + "synth_seconds": 2.248253800000384, + "transcribe_seconds": 0.9149532999981602 } ] \ No newline at end of file diff --git a/examples/assorted_checks/test_transcription/output_long_form/long_form_report.json b/examples/assorted_checks/test_transcription/output_long_form/long_form_report.json index 77fac1b2..90412ac2 100644 --- a/examples/assorted_checks/test_transcription/output_long_form/long_form_report.json +++ b/examples/assorted_checks/test_transcription/output_long_form/long_form_report.json @@ -5,14 +5,16 @@ "input_chars": 64996, "input_chars_cap": 65000, "audio_file": "output_long_form/long_form_af_heart.wav", - "audio_seconds": 3966.46, - "synth_seconds": 81.65, - "synth_realtime_factor": 48.58, + "audio_seconds": 3966.38, + "synth_seconds": 109.05, + "synth_realtime_factor": 36.37, "audio_file_mb": 181.57, "whisper_model": "base.en", - "transcribe_seconds": 294.12, - "transcribe_realtime_factor": 13.49, - "transcript_words": 11546, - "wer": 0.0481, + "whisper_device": "cuda", + "whisper_compute": "float16", + "transcribe_seconds": 63.57, + "transcribe_realtime_factor": 62.4, + "transcript_words": 11518, + "wer": 0.0466, "transcript_file": "output_long_form/long_form_af_heart.transcript.txt" } \ No newline at end of file diff --git a/examples/assorted_checks/test_transcription/output_multilingual/report.json b/examples/assorted_checks/test_transcription/output_multilingual/report.json new file mode 100644 index 00000000..46e2e926 --- /dev/null +++ b/examples/assorted_checks/test_transcription/output_multilingual/report.json @@ -0,0 +1,155 @@ +[ + { + "voice": "af_heart", + "lang": "en", + "metric": "WER", + "expected": "The quick brown fox jumps over the lazy dog.", + "transcript": "The quick brown fox jumps over the lazy dog.", + "score": 0.0, + "threshold": 0.2, + "passed": true, + "detected_lang": "en", + "detected_prob": 1, + "audio_path": "output_multilingual/af_heart_en.wav", + "audio_bytes": 138788, + "synth_seconds": 1.096960000002582, + "transcribe_seconds": 2.6913165000005392, + "error": "" + }, + { + "voice": "bf_emma", + "lang": "en", + "metric": "WER", + "expected": "The rain in Spain falls mainly on the plain.", + "transcript": "The rain in Spain falls mainly on the plane.", + "score": 0.1111111111111111, + "threshold": 0.25, + "passed": true, + "detected_lang": "en", + "detected_prob": 1, + "audio_path": "output_multilingual/bf_emma_en.wav", + "audio_bytes": 121672, + "synth_seconds": 1.1128214000018488, + "transcribe_seconds": 2.689043499998661, + "error": "" + }, + { + "voice": "ef_dora", + "lang": "es", + "metric": "WER", + "expected": "El sol brilla en el cielo azul.", + "transcript": "El sol brilla en el cielo azul.", + "score": 0.0, + "threshold": 0.3, + "passed": true, + "detected_lang": "es", + "detected_prob": 1, + "audio_path": "output_multilingual/ef_dora_es.wav", + "audio_bytes": 100522, + "synth_seconds": 0.37307390000205487, + "transcribe_seconds": 2.638828499999363, + "error": "" + }, + { + "voice": "ff_siwis", + "lang": "fr", + "metric": "WER", + "expected": "Le soleil brille dans le ciel bleu.", + "transcript": "Le soleil brille dans le ciel bleu.", + "score": 0.0, + "threshold": 0.2, + "passed": true, + "detected_lang": "fr", + "detected_prob": 1, + "audio_path": "output_multilingual/ff_siwis_fr.wav", + "audio_bytes": 95686, + "synth_seconds": 0.4655084000005445, + "transcribe_seconds": 2.7144860999978846, + "error": "" + }, + { + "voice": "if_sara", + "lang": "it", + "metric": "WER", + "expected": "Il gatto dorme sul tappeto rosso.", + "transcript": "Il gatto dorme sul tappeto rosso.", + "score": 0.0, + "threshold": 0.3, + "passed": true, + "detected_lang": "it", + "detected_prob": 1, + "audio_path": "output_multilingual/if_sara_it.wav", + "audio_bytes": 120668, + "synth_seconds": 0.47659769999881973, + "transcribe_seconds": 2.968207899997651, + "error": "" + }, + { + "voice": "pf_dora", + "lang": "pt", + "metric": "WER", + "expected": "O gato dorme no tapete vermelho.", + "transcript": "O gato dorme no tapete vermelho.", + "score": 0.0, + "threshold": 0.3, + "passed": true, + "detected_lang": "pt", + "detected_prob": 1, + "audio_path": "output_multilingual/pf_dora_pt.wav", + "audio_bytes": 112590, + "synth_seconds": 0.4205364000008558, + "transcribe_seconds": 2.666296700001112, + "error": "" + }, + { + "voice": "hf_alpha", + "lang": "hi", + "metric": "CER", + "expected": "आज मौसम बहुत अच्छा है।", + "transcript": "आज मोसम बहुत अच्छा है", + "score": 0.058823529411764705, + "threshold": 0.2, + "passed": true, + "detected_lang": "hi", + "detected_prob": 1, + "audio_path": "output_multilingual/hf_alpha_hi.wav", + "audio_bytes": 115704, + "synth_seconds": 0.43928260000029695, + "transcribe_seconds": 2.8490301000019826, + "error": "" + }, + { + "voice": "jf_alpha", + "lang": "ja", + "metric": "CER", + "expected": "今日はとても良い天気です。", + "transcript": "今日はとても良い天気です", + "score": 0.0, + "threshold": 0.3, + "passed": true, + "detected_lang": "ja", + "detected_prob": 1, + "audio_path": "output_multilingual/jf_alpha_ja.wav", + "audio_bytes": 99206, + "synth_seconds": 0.7097135999974853, + "transcribe_seconds": 2.6892165999997815, + "error": "" + }, + { + "voice": "zf_xiaobei", + "lang": "zh", + "metric": "CER", + "expected": "今天天气非常好。", + "transcript": "今天天氣非常好", + "score": 0.14285714285714285, + "threshold": 0.25, + "passed": true, + "detected_lang": "zh", + "detected_prob": 1, + "audio_path": "output_multilingual/zf_xiaobei_zh.wav", + "audio_bytes": 107382, + "synth_seconds": 2.063969900002121, + "transcribe_seconds": 2.705509899999015, + "error": "" + } +] \ No newline at end of file diff --git a/examples/assorted_checks/test_transcription/output_multilingual_cpu_default/report.json b/examples/assorted_checks/test_transcription/output_multilingual_cpu_default/report.json new file mode 100644 index 00000000..1eda6853 --- /dev/null +++ b/examples/assorted_checks/test_transcription/output_multilingual_cpu_default/report.json @@ -0,0 +1,155 @@ +[ + { + "voice": "af_heart", + "lang": "en", + "metric": "WER", + "expected": "The quick brown fox jumps over the lazy dog.", + "transcript": "The quick brown fox jumps over the lazy dog.", + "score": 0.0, + "threshold": 0.2, + "passed": true, + "detected_lang": "en", + "detected_prob": 1, + "audio_path": "output_multilingual_cpu_default/af_heart_en.wav", + "audio_bytes": 138812, + "synth_seconds": 2.298271300001943, + "transcribe_seconds": 2.8226972999982536, + "error": "" + }, + { + "voice": "bf_emma", + "lang": "en", + "metric": "WER", + "expected": "The rain in Spain falls mainly on the plain.", + "transcript": "The rain in Spain falls mainly on the plane.", + "score": 0.1111111111111111, + "threshold": 0.25, + "passed": true, + "detected_lang": "en", + "detected_prob": 1, + "audio_path": "output_multilingual_cpu_default/bf_emma_en.wav", + "audio_bytes": 121672, + "synth_seconds": 2.285058499997831, + "transcribe_seconds": 2.746085700004187, + "error": "" + }, + { + "voice": "ef_dora", + "lang": "es", + "metric": "WER", + "expected": "El sol brilla en el cielo azul.", + "transcript": "El sol brilla en el cielo azul.", + "score": 0.0, + "threshold": 0.3, + "passed": true, + "detected_lang": "es", + "detected_prob": 1, + "audio_path": "output_multilingual_cpu_default/ef_dora_es.wav", + "audio_bytes": 100522, + "synth_seconds": 1.2294228000027942, + "transcribe_seconds": 2.6340122999972664, + "error": "" + }, + { + "voice": "ff_siwis", + "lang": "fr", + "metric": "WER", + "expected": "Le soleil brille dans le ciel bleu.", + "transcript": "Le soleil brille dans le ciel bleu.", + "score": 0.0, + "threshold": 0.2, + "passed": true, + "detected_lang": "fr", + "detected_prob": 1, + "audio_path": "output_multilingual_cpu_default/ff_siwis_fr.wav", + "audio_bytes": 95684, + "synth_seconds": 1.0878834999966784, + "transcribe_seconds": 2.8614803000018583, + "error": "" + }, + { + "voice": "if_sara", + "lang": "it", + "metric": "WER", + "expected": "Il gatto dorme sul tappeto rosso.", + "transcript": "Il gatto dorme sul tappeto rosso.", + "score": 0.0, + "threshold": 0.3, + "passed": true, + "detected_lang": "it", + "detected_prob": 1, + "audio_path": "output_multilingual_cpu_default/if_sara_it.wav", + "audio_bytes": 120668, + "synth_seconds": 1.2805765000011888, + "transcribe_seconds": 3.6586665000068024, + "error": "" + }, + { + "voice": "pf_dora", + "lang": "pt", + "metric": "WER", + "expected": "O gato dorme no tapete vermelho.", + "transcript": "O gato dorme no tapete vermelho.", + "score": 0.0, + "threshold": 0.3, + "passed": true, + "detected_lang": "pt", + "detected_prob": 1, + "audio_path": "output_multilingual_cpu_default/pf_dora_pt.wav", + "audio_bytes": 112590, + "synth_seconds": 1.2797040000004927, + "transcribe_seconds": 3.0973912000044947, + "error": "" + }, + { + "voice": "hf_alpha", + "lang": "hi", + "metric": "CER", + "expected": "आज मौसम बहुत अच्छा है।", + "transcript": "आज मोसम बहुत अच्छा हे", + "score": 0.11764705882352941, + "threshold": 0.2, + "passed": true, + "detected_lang": "hi", + "detected_prob": 1, + "audio_path": "output_multilingual_cpu_default/hf_alpha_hi.wav", + "audio_bytes": 115594, + "synth_seconds": 1.5261495000013383, + "transcribe_seconds": 2.9073769000024186, + "error": "" + }, + { + "voice": "jf_alpha", + "lang": "ja", + "metric": "CER", + "expected": "今日はとても良い天気です。", + "transcript": "今日はとても良い天気です", + "score": 0.0, + "threshold": 0.3, + "passed": true, + "detected_lang": "ja", + "detected_prob": 1, + "audio_path": "output_multilingual_cpu_default/jf_alpha_ja.wav", + "audio_bytes": 99100, + "synth_seconds": 1.2916938999987906, + "transcribe_seconds": 2.5406462000028114, + "error": "" + }, + { + "voice": "zf_xiaobei", + "lang": "zh", + "metric": "CER", + "expected": "今天天气非常好。", + "transcript": "今天天氣非常好", + "score": 0.14285714285714285, + "threshold": 0.25, + "passed": true, + "detected_lang": "zh", + "detected_prob": 1, + "audio_path": "output_multilingual_cpu_default/zf_xiaobei_zh.wav", + "audio_bytes": 107298, + "synth_seconds": 2.9722165999992285, + "transcribe_seconds": 2.506729399996402, + "error": "" + } +] \ No newline at end of file diff --git a/examples/assorted_checks/test_transcription/output_multilingual_cpu_noja/report.json b/examples/assorted_checks/test_transcription/output_multilingual_cpu_noja/report.json new file mode 100644 index 00000000..ec547885 --- /dev/null +++ b/examples/assorted_checks/test_transcription/output_multilingual_cpu_noja/report.json @@ -0,0 +1,155 @@ +[ + { + "voice": "af_heart", + "lang": "en", + "metric": "WER", + "expected": "The quick brown fox jumps over the lazy dog.", + "transcript": "The quick brown fox jumps over the lazy dog.", + "score": 0.0, + "threshold": 0.2, + "passed": true, + "detected_lang": "en", + "detected_prob": 1, + "audio_path": "output_multilingual_cpu_noja/af_heart_en.wav", + "audio_bytes": 138830, + "synth_seconds": 2.211528299994825, + "transcribe_seconds": 2.905726799996046, + "error": "" + }, + { + "voice": "bf_emma", + "lang": "en", + "metric": "WER", + "expected": "The rain in Spain falls mainly on the plain.", + "transcript": "The rain in Spain falls mainly on the plane.", + "score": 0.1111111111111111, + "threshold": 0.25, + "passed": true, + "detected_lang": "en", + "detected_prob": 1, + "audio_path": "output_multilingual_cpu_noja/bf_emma_en.wav", + "audio_bytes": 121672, + "synth_seconds": 2.505469299998367, + "transcribe_seconds": 3.258189100000891, + "error": "" + }, + { + "voice": "ef_dora", + "lang": "es", + "metric": "WER", + "expected": "El sol brilla en el cielo azul.", + "transcript": "El sol brilla en el cielo azul.", + "score": 0.0, + "threshold": 0.3, + "passed": true, + "detected_lang": "es", + "detected_prob": 1, + "audio_path": "output_multilingual_cpu_noja/ef_dora_es.wav", + "audio_bytes": 100522, + "synth_seconds": 1.1369634000002407, + "transcribe_seconds": 3.1840576000031433, + "error": "" + }, + { + "voice": "ff_siwis", + "lang": "fr", + "metric": "WER", + "expected": "Le soleil brille dans le ciel bleu.", + "transcript": "Le soleil brille dans le ciel bleu.", + "score": 0.0, + "threshold": 0.2, + "passed": true, + "detected_lang": "fr", + "detected_prob": 1, + "audio_path": "output_multilingual_cpu_noja/ff_siwis_fr.wav", + "audio_bytes": 95690, + "synth_seconds": 1.2329757999978028, + "transcribe_seconds": 2.9903894000017317, + "error": "" + }, + { + "voice": "if_sara", + "lang": "it", + "metric": "WER", + "expected": "Il gatto dorme sul tappeto rosso.", + "transcript": "Il gatto dorme sul tappeto rosso.", + "score": 0.0, + "threshold": 0.3, + "passed": true, + "detected_lang": "it", + "detected_prob": 1, + "audio_path": "output_multilingual_cpu_noja/if_sara_it.wav", + "audio_bytes": 120670, + "synth_seconds": 1.3126929999998538, + "transcribe_seconds": 2.9695748000012827, + "error": "" + }, + { + "voice": "pf_dora", + "lang": "pt", + "metric": "WER", + "expected": "O gato dorme no tapete vermelho.", + "transcript": "O gato dorme no tapete vermelho.", + "score": 0.0, + "threshold": 0.3, + "passed": true, + "detected_lang": "pt", + "detected_prob": 1, + "audio_path": "output_multilingual_cpu_noja/pf_dora_pt.wav", + "audio_bytes": 112592, + "synth_seconds": 1.1745988999973633, + "transcribe_seconds": 3.2026891999994405, + "error": "" + }, + { + "voice": "hf_alpha", + "lang": "hi", + "metric": "CER", + "expected": "आज मौसम बहुत अच्छा है।", + "transcript": "आज मोसम बहुत अच्छा हे", + "score": 0.11764705882352941, + "threshold": 0.2, + "passed": true, + "detected_lang": "hi", + "detected_prob": 1, + "audio_path": "output_multilingual_cpu_noja/hf_alpha_hi.wav", + "audio_bytes": 115658, + "synth_seconds": 1.560672200001136, + "transcribe_seconds": 3.383672600000864, + "error": "" + }, + { + "voice": "jf_alpha", + "lang": "ja", + "metric": "CER", + "expected": "今日はとても良い天気です。", + "transcript": "", + "score": 1.0, + "threshold": 0.3, + "passed": false, + "detected_lang": "", + "detected_prob": 0.0, + "audio_path": "output_multilingual_cpu_noja/jf_alpha_ja.wav", + "audio_bytes": 0, + "synth_seconds": 0.1126732999982778, + "transcribe_seconds": 0.0, + "error": "empty_audio (returned 0B WAV)" + }, + { + "voice": "zf_xiaobei", + "lang": "zh", + "metric": "CER", + "expected": "今天天气非常好。", + "transcript": "今天天氣非常好", + "score": 0.14285714285714285, + "threshold": 0.25, + "passed": true, + "detected_lang": "zh", + "detected_prob": 1, + "audio_path": "output_multilingual_cpu_noja/zf_xiaobei_zh.wav", + "audio_bytes": 107296, + "synth_seconds": 3.156451199996809, + "transcribe_seconds": 3.1286701999997604, + "error": "" + } +] \ No newline at end of file diff --git a/examples/assorted_checks/test_transcription/run_long_form.bat b/examples/assorted_checks/test_transcription/run_long_form.bat index a4c46dcc..c005d881 100644 --- a/examples/assorted_checks/test_transcription/run_long_form.bat +++ b/examples/assorted_checks/test_transcription/run_long_form.bat @@ -1,14 +1,18 @@ @echo off -REM Long-form runner. Usage: run_long_form.bat [full|synth|transcribe|short] -REM full synth + transcribe on the whole book (default) +REM Long-form runner. Usage: run_long_form.bat [short|full|synth|transcribe] +REM short synth + transcribe on a 65k-char slice (~chapters 1-7) [default] +REM full synth + transcribe on the whole book REM synth synth only, no transcription (fast on GPU) -REM short synth + transcribe on a 65k-char slice (~chapters 1-7) REM transcribe transcribe only, reuses the wav from a previous synth run -REM Double-click runs 'full'. Output streams to the window and to a log file. +REM Double-click runs 'short'. Output streams to the window and to a log file. setlocal set "MODE=%~1" -if "%MODE%"=="" set "MODE=full" +if "%MODE%"=="" set "MODE=short" + +REM Whisper defaults; set these before invoking the .bat to override. +if not defined WHISPER_DEVICE set "WHISPER_DEVICE=cuda" +if not defined WHISPER_MODEL set "WHISPER_MODEL=base.en" set "EXTRA_ARGS=" if /I "%MODE%"=="full" goto :full @@ -46,6 +50,7 @@ if not exist "%LOG_DIR%" mkdir "%LOG_DIR%" pushd "%REPO_ROOT%" powershell -NoProfile -ExecutionPolicy Bypass -Command ^ + "$env:VIRTUAL_ENV = $null;" ^ "Write-Output ('Run started ' + (Get-Date)) | Tee-Object -FilePath '%LOG_FILE%';" ^ "uv run --project examples python examples/assorted_checks/test_transcription/test_long_form.py %EXTRA_ARGS% 2>&1 | Tee-Object -FilePath '%LOG_FILE%' -Append;" ^ "Write-Output ('Run finished ' + (Get-Date)) | Tee-Object -FilePath '%LOG_FILE%' -Append;" diff --git a/examples/assorted_checks/test_transcription/test_long_form.py b/examples/assorted_checks/test_transcription/test_long_form.py index e73ffc27..7c3b4fdb 100644 --- a/examples/assorted_checks/test_transcription/test_long_form.py +++ b/examples/assorted_checks/test_transcription/test_long_form.py @@ -1,13 +1,15 @@ """Long-form roundtrip baseline. -Synthesizes a long input file with one voice via a running Kokoro server, -transcribes the result with faster-whisper, and reports timing + WER. Goal: -establish a baseline for "can the server produce 30-60 min of clean audio -in one go, and how long does it take" — not to assert pass/fail. +Synthesizes a long input file via a running Kokoro server, transcribes the +result with faster-whisper, and reports timing + WER. Captures baseline +numbers for multi-hour synthesis runs; not a pass/fail test. -Usage: - uv sync --extra transcription - uv run python assorted_checks/test_transcription/test_long_form.py +Usage (from repo root): + uv sync --project examples --extra transcription + uv run --project examples python examples/assorted_checks/test_transcription/test_long_form.py + +On Windows, run_long_form.bat wraps this with logging and named modes +(short / full / synth / transcribe). Env overrides: KOKORO_BASE_URL default http://localhost:8880/v1 @@ -15,6 +17,8 @@ LONGFORM_INPUT default input/journey_all.txt.gz (relative to this script; .gz auto-decoded) LONGFORM_CHARS default unset (full file); int caps cleaned input length WHISPER_MODEL default base.en + WHISPER_DEVICE default cpu (set "cuda" for GPU) + WHISPER_COMPUTE default int8 on cpu, float16 on cuda """ from __future__ import annotations @@ -34,6 +38,10 @@ BASE_URL = os.environ.get("KOKORO_BASE_URL", "http://localhost:8880/v1") VOICE = os.environ.get("LONGFORM_VOICE", "af_heart") WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "base.en") +WHISPER_DEVICE = os.environ.get("WHISPER_DEVICE", "cpu").lower() +WHISPER_COMPUTE = os.environ.get( + "WHISPER_COMPUTE", "float16" if WHISPER_DEVICE == "cuda" else "int8" +) SCRIPT_DIR = Path(__file__).parent INPUT_PATH = Path(os.environ.get("LONGFORM_INPUT", SCRIPT_DIR / "input" / "journey_all.txt.gz")) @@ -41,7 +49,7 @@ def rel(path: Path) -> str: - """Path relative to the script dir, posix-style — keeps reports portable.""" + """Path relative to the script dir, posix-style.""" return path.resolve().relative_to(SCRIPT_DIR.resolve()).as_posix() @@ -123,9 +131,7 @@ def fix_streaming_wav_header(wav_path: Path) -> None: if chunk_id == b"data": data_size_offset = f.tell() - 4 break - # Skip body (chunk_size from header, but it's the placeholder for - # streaming; jump to end-of-file for the data chunk if we're not - # there yet — for non-data chunks, trust the header). + # Non-data chunk: trust its header size and skip past. f.seek(chunk_size, 1) if data_size_offset is None: @@ -145,14 +151,27 @@ def audio_duration_seconds(wav_path: Path) -> float: return wf.getnframes() / float(wf.getframerate()) -def transcribe(model, audio_path: Path) -> tuple[str, float]: +def transcribe(model, audio_path: Path, total_audio_s: float | None = None) -> tuple[str, float]: start = time.perf_counter() segments, _info = model.transcribe( str(audio_path), beam_size=1, vad_filter=True, ) - text = " ".join(seg.text for seg in segments).strip() + parts: list[str] = [] + last_print = 0.0 + for seg in segments: + parts.append(seg.text) + # Heartbeat every ~30s of audio processed so long runs aren't silent. + if seg.end - last_print >= 30.0: + elapsed = time.perf_counter() - start + if total_audio_s: + pct = 100.0 * seg.end / total_audio_s + print(f" [{fmt_time(elapsed)}] {fmt_time(seg.end)} / {fmt_time(total_audio_s)} ({pct:.0f}%)", flush=True) + else: + print(f" [{fmt_time(elapsed)}] {fmt_time(seg.end)}", flush=True) + last_print = seg.end + text = " ".join(parts).strip() return text, time.perf_counter() - start @@ -185,6 +204,20 @@ def main() -> int: OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + audio_path = OUTPUT_DIR / f"long_form_{VOICE}.wav" + synth_meta_path = OUTPUT_DIR / f"long_form_{VOICE}.synth_meta.json" + + # Transcribe-only inherits the exact char cap the synth used, so the + # reference text matches what's actually in the wav. + if args.transcribe_only and char_cap is None and synth_meta_path.exists(): + try: + inherited = json.loads(synth_meta_path.read_text()).get("input_chars_cap") + if inherited is not None: + char_cap = int(inherited) + print(f"Inheriting char cap from prior synth: {char_cap}") + except Exception: + pass + if INPUT_PATH.suffix == ".gz": with gzip.open(INPUT_PATH, "rt", encoding="utf-8") as f: raw = f.read() @@ -204,11 +237,9 @@ def main() -> int: print(f"Input: {INPUT_PATH.name} ({word_count} words, {char_count} chars; capped at {char_cap} of {full_chars})") else: print(f"Input: {INPUT_PATH.name} ({word_count} words, {char_count} chars)") - print(f"Whisper: {WHISPER_MODEL} (CPU, int8, VAD on)") + print(f"Whisper: {WHISPER_MODEL} ({WHISPER_DEVICE}, {WHISPER_COMPUTE}, VAD on)") print() - audio_path = OUTPUT_DIR / f"long_form_{VOICE}.wav" - if args.transcribe_only: if not audio_path.exists(): print(f"--transcribe-only set but no audio at {audio_path}") @@ -232,6 +263,12 @@ def main() -> int: audio_s = audio_duration_seconds(audio_path) synth_rtf = audio_s / synth_s if synth_s > 0 else 0.0 file_mb = audio_path.stat().st_size / (1024 * 1024) + synth_meta_path.write_text(json.dumps({ + "input_file": rel(INPUT_PATH), + "input_chars_cap": char_cap, + "input_chars": char_count, + "voice": VOICE, + }, indent=2)) print(f" synth time: {fmt_time(synth_s)}") print(f" audio length: {fmt_time(audio_s)}") print(f" synth speedup: {synth_rtf:.2f}x realtime") @@ -265,11 +302,11 @@ def main() -> int: normalize = _build_normalizer() - print("Loading Whisper model...") - model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8") + print(f"Loading Whisper model ({WHISPER_DEVICE}, {WHISPER_COMPUTE})...") + model = WhisperModel(WHISPER_MODEL, device=WHISPER_DEVICE, compute_type=WHISPER_COMPUTE) - print("Transcribing (this can take a while on CPU)...") - transcript, trans_s = transcribe(model, audio_path) + print("Transcribing...") + transcript, trans_s = transcribe(model, audio_path, audio_s) trans_rtf = audio_s / trans_s if trans_s > 0 else 0.0 print(f" transcribe time: {fmt_time(trans_s)}") print(f" transcribe speedup: {trans_rtf:.2f}x realtime") @@ -290,6 +327,8 @@ def main() -> int: report.update({ "whisper_model": WHISPER_MODEL, + "whisper_device": WHISPER_DEVICE, + "whisper_compute": WHISPER_COMPUTE, "transcribe_seconds": round(trans_s, 2), "transcribe_realtime_factor": round(trans_rtf, 2), "transcript_words": len(transcript.split()), @@ -300,7 +339,10 @@ def main() -> int: report_path.write_text(json.dumps(report, indent=2)) print("Summary") - print(f" generated {fmt_time(audio_s)} of audio in {fmt_time(synth_s)} ({synth_rtf:.1f}x rt)") + if synth_s > 0: + print(f" generated {fmt_time(audio_s)} of audio in {fmt_time(synth_s)} ({synth_rtf:.1f}x rt)") + else: + print(f" reused {fmt_time(audio_s)} of audio (no resynth)") print(f" transcribed in {fmt_time(trans_s)} ({trans_rtf:.1f}x rt)") print(f" WER {score:.4f} vs cleaned input") print(f" report: {rel(report_path)}") diff --git a/examples/assorted_checks/test_transcription/test_transcription.py b/examples/assorted_checks/test_transcription/test_transcription.py index e40d101e..2dded495 100644 --- a/examples/assorted_checks/test_transcription/test_transcription.py +++ b/examples/assorted_checks/test_transcription/test_transcription.py @@ -4,9 +4,9 @@ locally with faster-whisper, and reports word error rate against the expected text. Intended as a manual sanity check, not a pytest suite. -Usage: - uv sync --extra transcription - uv run python assorted_checks/test_transcription/test_transcription.py +Usage (from repo root): + uv sync --project examples --extra transcription + uv run --project examples python examples/assorted_checks/test_transcription/test_transcription.py Env overrides: KOKORO_BASE_URL default http://localhost:8880/v1 @@ -74,7 +74,7 @@ def _normalized_wer(reference: str, hypothesis: str) -> float: def rel(path: Path) -> str: - """Path relative to the script dir, posix-style — keeps reports portable.""" + """Path relative to the script dir, posix-style.""" return path.resolve().relative_to(SCRIPT_DIR.resolve()).as_posix() CASES: list[tuple[str, str]] = [ diff --git a/examples/assorted_checks/test_transcription/test_transcription_multilingual.py b/examples/assorted_checks/test_transcription/test_transcription_multilingual.py new file mode 100644 index 00000000..03b6397e --- /dev/null +++ b/examples/assorted_checks/test_transcription/test_transcription_multilingual.py @@ -0,0 +1,244 @@ +"""Multilingual TTS roundtrip validation. + +Same idea as test_transcription.py, but with multilingual Whisper and +per-language reference phrases. CER for ja/zh (no word boundaries), WER for +everything else. Intended as a manual sanity check across Kokoro's non-English +voices — especially Japanese, which fails quietly often enough to want a +dedicated harness. + +Usage (from repo root): + uv sync --project examples --extra transcription + uv run --project examples python examples/assorted_checks/test_transcription/test_transcription_multilingual.py + +Env overrides: + KOKORO_BASE_URL default http://localhost:8880/v1 + WHISPER_MODEL default small (multilingual; ~470MB int8) + WHISPER_DEVICE default cpu + WHISPER_COMPUTE default int8 on cpu, float16 on cuda + +Note on silent failures: when Kokoro emits silence/garbage, Whisper sometimes +hallucinates a canned phrase under a forced language hint (e.g. Japanese → +"ありがとうございました"). The score will still be poor; just don't be surprised +when the transcript reads as something the audio doesn't say. +""" + +from __future__ import annotations + +import json +import os +import sys +import time +import unicodedata +from dataclasses import asdict, dataclass +from pathlib import Path + +# Windows stdout defaults to cp1252 — re-encode so Devanagari / CJK +# reference strings and transcripts print without crashing. +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + +import openai +from faster_whisper import WhisperModel +from jiwer import cer, wer +from jiwer.transforms import ( + Compose, + ReduceToListOfListOfWords, + RemoveMultipleSpaces, + RemovePunctuation, + Strip, + ToLowerCase, +) + + +BASE_URL = os.environ.get("KOKORO_BASE_URL", "http://localhost:8880/v1") +WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "small") +WHISPER_DEVICE = os.environ.get("WHISPER_DEVICE", "cpu") +WHISPER_COMPUTE = os.environ.get( + "WHISPER_COMPUTE", "int8" if WHISPER_DEVICE == "cpu" else "float16" +) + +SCRIPT_DIR = Path(__file__).parent +OUTPUT_DIR = SCRIPT_DIR / "output_multilingual" + + +# (voice, whisper ISO lang code, expected text, pass threshold) +# CJK uses CER, the rest WER. Thresholds are first-pass and deliberately +# loose — tighten after we see what `small` actually delivers. +CASES: list[tuple[str, str, str, float]] = [ + ("af_heart", "en", "The quick brown fox jumps over the lazy dog.", 0.20), + ("bf_emma", "en", "The rain in Spain falls mainly on the plain.", 0.25), + ("ef_dora", "es", "El sol brilla en el cielo azul.", 0.30), + ("ff_siwis", "fr", "Le soleil brille dans le ciel bleu.", 0.20), + ("if_sara", "it", "Il gatto dorme sul tappeto rosso.", 0.30), + ("pf_dora", "pt", "O gato dorme no tapete vermelho.", 0.30), + ("hf_alpha", "hi", "आज मौसम बहुत अच्छा है।", 0.20), + ("jf_alpha", "ja", "今日はとても良い天気です。", 0.30), + ("zf_xiaobei", "zh", "今天天气非常好。", 0.25), +] + +# Hindi uses combining diacritics that make single-vowel mistakes count as +# whole-word substitutions under WER; CER is the right metric for any script +# where character-level diffs dominate, not just CJK. +CER_LANGS = {"hi", "ja", "zh"} + + +_WORD_NORMALIZE = Compose([ + ToLowerCase(), + RemovePunctuation(), + RemoveMultipleSpaces(), + Strip(), + ReduceToListOfListOfWords(), +]) + + +def _strip_for_cer(s: str) -> str: + return "".join( + c for c in s + if not c.isspace() and not unicodedata.category(c).startswith("P") + ) + + +def _score(lang: str, reference: str, hypothesis: str) -> float: + if lang in CER_LANGS: + return cer(_strip_for_cer(reference), _strip_for_cer(hypothesis)) + return wer( + reference, + hypothesis, + reference_transform=_WORD_NORMALIZE, + hypothesis_transform=_WORD_NORMALIZE, + ) + + +@dataclass +class Result: + voice: str + lang: str + metric: str + expected: str + transcript: str + score: float + threshold: float + passed: bool + detected_lang: str + detected_prob: float + audio_path: str + audio_bytes: int + synth_seconds: float + transcribe_seconds: float + error: str = "" + + +def rel(path: Path) -> str: + return path.resolve().relative_to(SCRIPT_DIR.resolve()).as_posix() + + +def synthesize(client: openai.OpenAI, voice: str, text: str, out_path: Path) -> float: + start = time.perf_counter() + response = client.audio.speech.create( + model="tts-1", + voice=voice, + input=text, + response_format="wav", + ) + out_path.write_bytes(response.content) + return time.perf_counter() - start + + +def transcribe( + model: WhisperModel, audio_path: Path, language: str +) -> tuple[str, str, float, float]: + start = time.perf_counter() + segments, info = model.transcribe( + str(audio_path), beam_size=1, language=language + ) + text = " ".join(seg.text for seg in segments).strip() + return text, info.language, info.language_probability, time.perf_counter() - start + + +def main() -> int: + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + print(f"Server: {BASE_URL}") + print(f"Whisper: {WHISPER_MODEL} ({WHISPER_DEVICE}, {WHISPER_COMPUTE})") + print() + + client = openai.OpenAI(base_url=BASE_URL, api_key="not-needed", timeout=120) + + print(f"Loading Whisper model {WHISPER_MODEL!r} (first run downloads weights)...") + model = WhisperModel(WHISPER_MODEL, device=WHISPER_DEVICE, compute_type=WHISPER_COMPUTE) + print("Loaded.\n") + + results: list[Result] = [] + for voice, lang, expected, threshold in CASES: + audio_path = OUTPUT_DIR / f"{voice}_{lang}.wav" + metric = "CER" if lang in CER_LANGS else "WER" + print(f"[{voice} / {lang}] {expected!r}") + + def _fail(error: str, synth_s: float = 0.0, trans_s: float = 0.0) -> None: + audio_bytes = audio_path.stat().st_size if audio_path.exists() else 0 + print(f" FAIL ({error}) audio={audio_bytes}B\n") + results.append( + Result( + voice=voice, lang=lang, metric=metric, expected=expected, + transcript="", score=1.0, threshold=threshold, passed=False, + detected_lang="", detected_prob=0.0, + audio_path=rel(audio_path), audio_bytes=audio_bytes, + synth_seconds=synth_s, transcribe_seconds=trans_s, + error=error, + ) + ) + + try: + synth_s = synthesize(client, voice, expected, audio_path) + except Exception as exc: + _fail(f"synth_error: {exc}") + continue + + audio_bytes = audio_path.stat().st_size + # Catches Kokoro's silent-fail mode (notably Japanese): the HTTP call + # returns 200 with an empty/near-empty body, so we never see an + # exception — only a zero-byte WAV that downstream tooling chokes on. + if audio_bytes < 100: + _fail(f"empty_audio (returned {audio_bytes}B WAV)", synth_s=synth_s) + continue + + try: + transcript, det_lang, det_prob, trans_s = transcribe(model, audio_path, lang) + except Exception as exc: + _fail(f"transcribe_error: {exc}", synth_s=synth_s) + continue + + score = _score(lang, expected, transcript) + passed = score < threshold + + print(f" heard: {transcript!r}") + print(f" detected: lang={det_lang} (p={det_prob:.2f})") + print(f" {metric}: {score:.3f} threshold {threshold} ({'PASS' if passed else 'FAIL'})") + print(f" timing: synth {synth_s:.2f}s, transcribe {trans_s:.2f}s\n") + + results.append( + Result( + voice=voice, lang=lang, metric=metric, expected=expected, + transcript=transcript, score=score, threshold=threshold, passed=passed, + detected_lang=det_lang, detected_prob=det_prob, + audio_path=rel(audio_path), audio_bytes=audio_bytes, + synth_seconds=synth_s, transcribe_seconds=trans_s, + ) + ) + + report_path = OUTPUT_DIR / "report.json" + report_path.write_text( + json.dumps([asdict(r) for r in results], indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + total = len(results) + passed_count = sum(1 for r in results if r.passed) + print(f"Summary: {passed_count}/{total} passed. Report: {rel(report_path)}") + + return 0 if results and passed_count == total else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/pyproject.toml b/examples/pyproject.toml index 4271776a..19caaebc 100644 --- a/examples/pyproject.toml +++ b/examples/pyproject.toml @@ -23,8 +23,13 @@ benchmarks = [ ] transcription = [ "faster-whisper>=1.0.0", + "hf-transfer>=0.1.9", "jiwer>=3.0.0", ] +transcription-gpu = [ + "nvidia-cublas-cu12>=12.9.2.10", + "nvidia-cudnn-cu12>=9.22.0.52", +] [tool.uv] package = false From c84adf35567a58d61843768869421adcd5370437 Mon Sep 17 00:00:00 2001 From: remsky Date: Mon, 11 May 2026 01:41:20 -0600 Subject: [PATCH 12/45] fix(tests): drop tautological load_model_validation docs(readme): refresh badges --- README.md | 4 ++-- api/src/inference/kokoro_v1.py | 4 ++-- api/tests/test_kokoro_v1.py | 7 ------- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 12b18849..f0dbfdca 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@

# _`FastKoko`_ -[![Tests](https://img.shields.io/badge/tests-69-darkgreen)]() -[![Coverage](https://img.shields.io/badge/coverage-54%25-tan)]() +[![Tests](https://img.shields.io/badge/tests-81-darkgreen)]() +[![Coverage](https://img.shields.io/badge/coverage-52%25-tan)]() [![Try on Spaces](https://img.shields.io/badge/%F0%9F%A4%97%20Try%20on-Spaces-blue)](https://huggingface.co/spaces/Remsky/Kokoro-TTS-Zero) [![Kokoro](https://img.shields.io/badge/kokoro-0.9.2-BB5420)](https://github.com/hexgrad/kokoro) diff --git a/api/src/inference/kokoro_v1.py b/api/src/inference/kokoro_v1.py index 5b201008..dc11ae3a 100644 --- a/api/src/inference/kokoro_v1.py +++ b/api/src/inference/kokoro_v1.py @@ -78,8 +78,8 @@ async def load_model(self, path: str) -> None: else: self._model = self._model.cpu() - except FileNotFoundError as e: - raise e + except FileNotFoundError: + raise except Exception as e: raise RuntimeError(f"Failed to load Kokoro model: {e}") diff --git a/api/tests/test_kokoro_v1.py b/api/tests/test_kokoro_v1.py index ff3fc072..ff02d9a0 100644 --- a/api/tests/test_kokoro_v1.py +++ b/api/tests/test_kokoro_v1.py @@ -49,13 +49,6 @@ def test_clear_memory(mock_sync, mock_clear, kokoro_backend): mock_sync.assert_called_once() -@pytest.mark.asyncio -async def test_load_model_validation(kokoro_backend): - """Test model loading validation: missing file surfaces FileNotFoundError unwrapped.""" - with pytest.raises(FileNotFoundError): - await kokoro_backend.load_model("nonexistent_model.pth") - - def test_unload_with_pipelines(kokoro_backend): """Test model unloading with multiple pipelines.""" # Mock loaded state with multiple pipelines From ff8252a4dfdb76edbc52f50bb4bb0fbce8629f67 Mon Sep 17 00:00:00 2001 From: remsky Date: Thu, 14 May 2026 17:15:07 -0600 Subject: [PATCH 13/45] ci(release): pin buildkit/runners, fix workflow_dispatch ref + tag-check race, gate latest --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 79 ++++++++++++++++++++------------ .github/workflows/test_build.yml | 8 ++-- docker-bake.hcl | 39 +++++++--------- docker/gpu/Dockerfile.optimized | 8 +++- pyproject.toml | 15 ++++-- 6 files changed, 89 insertions(+), 62 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abfcd7ed..da61e870 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: branches: [ "master", "pre-release" ] jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: matrix: python-version: ["3.10"] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4c12bfa..e8468c53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,26 +14,38 @@ on: required: true type: string +concurrency: + group: release-${{ github.event_name == 'workflow_dispatch' && inputs.branch_name || github.ref_name }} + cancel-in-progress: false + jobs: prepare-release: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: version: ${{ steps.get-version.outputs.version }} version_tag: ${{ steps.get-version.outputs.version_tag }} + source_ref: ${{ steps.resolve-ref.outputs.source_ref }} steps: + - name: Resolve source ref + id: resolve-ref + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "source_ref=${{ inputs.branch_name }}" >> $GITHUB_OUTPUT + else + echo "source_ref=${{ github.ref_name }}" >> $GITHUB_OUTPUT + fi + - name: Checkout repository uses: actions/checkout@v4 + with: + ref: ${{ steps.resolve-ref.outputs.source_ref }} + fetch-depth: 0 - name: Get version from VERSION file id: get-version run: | VERSION_PLAIN=$(cat VERSION) - - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - BRANCH_NAME="${{ inputs.branch_name }}" - else - BRANCH_NAME="${{ github.ref_name }}" - fi + BRANCH_NAME="${{ steps.resolve-ref.outputs.source_ref }}" if [[ "$BRANCH_NAME" == "release" ]]; then echo "version=${VERSION_PLAIN}" >> $GITHUB_OUTPUT @@ -44,6 +56,15 @@ jobs: echo "version_tag=v${VERSION_PLAIN}-${SAFE_BRANCH}" >> $GITHUB_OUTPUT fi + - name: Check tag does not exist + run: | + TAG_NAME="${{ steps.get-version.outputs.version_tag }}" + git fetch --tags + if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then + echo "::error::Tag $TAG_NAME already exists. Please increment the version in the VERSION file." + exit 1 + fi + build-images: needs: prepare-release permissions: @@ -60,10 +81,10 @@ jobs: include: - build_target: "cpu" platform: "linux/amd64" - runs_on: "ubuntu-latest" + runs_on: "ubuntu-24.04" - build_target: "gpu" platform: "linux/amd64" - runs_on: "ubuntu-latest" + runs_on: "ubuntu-24.04" - build_target: "cpu" platform: "linux/arm64" runs_on: "ubuntu-24.04-arm" @@ -72,25 +93,13 @@ jobs: runs_on: "ubuntu-24.04-arm" - build_target: "rocm" platform: "linux/amd64" - runs_on: "ubuntu-latest" + runs_on: "ubuntu-24.04" runs-on: ${{ matrix.runs_on }} steps: - name: Checkout repository uses: actions/checkout@v4 with: - fetch-depth: 0 # Needed to check for existing tags - - - name: Check if tag already exists - run: | - TAG_NAME="${{ needs.prepare-release.outputs.version_tag }}" - echo "Checking for existing tag: $TAG_NAME" - git fetch --tags - if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then - echo "::error::Tag $TAG_NAME already exists. Please increment the version in the VERSION file." - exit 1 - else - echo "Tag $TAG_NAME does not exist. Proceeding with release." - fi + ref: ${{ needs.prepare-release.outputs.source_ref }} - name: Free disk space run: | @@ -106,7 +115,7 @@ jobs: uses: docker/setup-buildx-action@v3 # Use v3 with: driver-opts: | - image=moby/buildkit:latest + image=moby/buildkit:v0.21.1 network=host - name: Log in to GitHub Container Registry @@ -131,7 +140,7 @@ jobs: create-manifests: needs: [prepare-release, build-images] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: packages: write env: @@ -157,21 +166,30 @@ jobs: OWNER="${{ env.OWNER }}" REPO="${{ env.REPO }}" + # ROCm is x86-only, so the manifest references just the amd64 image. + if [[ "$TARGET" == "rocm" ]]; then + SOURCES=("${REGISTRY}/${OWNER}/${REPO}-${TARGET}:${VERSION_TAG}-amd64") + else + SOURCES=( + "${REGISTRY}/${OWNER}/${REPO}-${TARGET}:${VERSION_TAG}-amd64" + "${REGISTRY}/${OWNER}/${REPO}-${TARGET}:${VERSION_TAG}-arm64" + ) + fi + docker buildx imagetools create -t \ ${REGISTRY}/${OWNER}/${REPO}-${TARGET}:${VERSION_TAG} \ - ${REGISTRY}/${OWNER}/${REPO}-${TARGET}:${VERSION_TAG}-amd64 \ - ${REGISTRY}/${OWNER}/${REPO}-${TARGET}:${VERSION_TAG}-arm64 + "${SOURCES[@]}" + # Publish :latest only for clean release tags (no branch suffix). if [[ "$VERSION_TAG" != *"-"* ]]; then docker buildx imagetools create -t \ ${REGISTRY}/${OWNER}/${REPO}-${TARGET}:latest \ - ${REGISTRY}/${OWNER}/${REPO}-${TARGET}:${VERSION_TAG}-amd64 \ - ${REGISTRY}/${OWNER}/${REPO}-${TARGET}:${VERSION_TAG}-arm64 + "${SOURCES[@]}" fi create-release: needs: [prepare-release, create-manifests] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: write steps: @@ -184,6 +202,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.prepare-release.outputs.version_tag }} + target_commitish: ${{ needs.prepare-release.outputs.source_ref }} name: Release ${{ needs.prepare-release.outputs.version_tag }} generate_release_notes: true draft: false diff --git a/.github/workflows/test_build.yml b/.github/workflows/test_build.yml index 641ea5e6..d702ff65 100644 --- a/.github/workflows/test_build.yml +++ b/.github/workflows/test_build.yml @@ -36,7 +36,7 @@ concurrency: jobs: setup: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: @@ -44,11 +44,11 @@ jobs: id: set-matrix run: | ALL_TARGETS='[ - {"build_target":"cpu","platform":"amd64","runs_on":"ubuntu-latest"}, + {"build_target":"cpu","platform":"amd64","runs_on":"ubuntu-24.04"}, {"build_target":"cpu","platform":"arm64","runs_on":"ubuntu-24.04-arm"}, - {"build_target":"gpu","platform":"amd64","runs_on":"ubuntu-latest"}, + {"build_target":"gpu","platform":"amd64","runs_on":"ubuntu-24.04"}, {"build_target":"gpu","platform":"arm64","runs_on":"ubuntu-24.04-arm"}, - {"build_target":"rocm","platform":"amd64","runs_on":"ubuntu-latest"} + {"build_target":"rocm","platform":"amd64","runs_on":"ubuntu-24.04"} ]' FILTERED=$(echo "$ALL_TARGETS" | jq -c \ diff --git a/docker-bake.hcl b/docker-bake.hcl index 9db0df76..75ed2e01 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -45,19 +45,13 @@ target "cpu" { inherits = ["_cpu_base"] platforms = ["linux/amd64", "linux/arm64"] tags = [ - "${REGISTRY}/${OWNER}/${REPO}-cpu:${VERSION}", - "${REGISTRY}/${OWNER}/${REPO}-cpu:latest" + "${REGISTRY}/${OWNER}/${REPO}-cpu:${VERSION}" ] } -# GPU target with multi-platform support -target "gpu" { - inherits = ["_gpu_base"] - platforms = ["linux/amd64", "linux/arm64"] - tags = [ - "${REGISTRY}/${OWNER}/${REPO}-gpu:${VERSION}", - "${REGISTRY}/${OWNER}/${REPO}-gpu:latest" - ] +# GPU multi-platform: dispatches to per-arch targets so each gets its own CUDA_VERSION +group "gpu" { + targets = ["gpu-amd64", "gpu-arm64"] } # Base settings for AMD ROCm builds @@ -72,8 +66,7 @@ target "cpu-amd64" { inherits = ["_cpu_base"] platforms = ["linux/amd64"] tags = [ - "${REGISTRY}/${OWNER}/${REPO}-cpu:${VERSION}-amd64", - "${REGISTRY}/${OWNER}/${REPO}-cpu:latest-amd64" + "${REGISTRY}/${OWNER}/${REPO}-cpu:${VERSION}-amd64" ] } @@ -81,26 +74,29 @@ target "cpu-arm64" { inherits = ["_cpu_base"] platforms = ["linux/arm64"] tags = [ - "${REGISTRY}/${OWNER}/${REPO}-cpu:${VERSION}-arm64", - "${REGISTRY}/${OWNER}/${REPO}-cpu:latest-arm64" + "${REGISTRY}/${OWNER}/${REPO}-cpu:${VERSION}-arm64" ] } target "gpu-amd64" { inherits = ["_gpu_base"] platforms = ["linux/amd64"] + args = { + CUDA_VERSION = "12.6.3" + } tags = [ - "${REGISTRY}/${OWNER}/${REPO}-gpu:${VERSION}-amd64", - "${REGISTRY}/${OWNER}/${REPO}-gpu:latest-amd64" + "${REGISTRY}/${OWNER}/${REPO}-gpu:${VERSION}-amd64" ] } target "gpu-arm64" { inherits = ["_gpu_base"] platforms = ["linux/arm64"] + args = { + CUDA_VERSION = "12.9.1" + } tags = [ - "${REGISTRY}/${OWNER}/${REPO}-gpu:${VERSION}-arm64", - "${REGISTRY}/${OWNER}/${REPO}-gpu:latest-arm64" + "${REGISTRY}/${OWNER}/${REPO}-gpu:${VERSION}-arm64" ] } @@ -109,8 +105,7 @@ target "rocm-amd64" { inherits = ["_rocm_base"] platforms = ["linux/amd64"] tags = [ - "${REGISTRY}/${OWNER}/${REPO}-rocm:${VERSION}-amd64", - "${REGISTRY}/${OWNER}/${REPO}-rocm:latest-amd64" + "${REGISTRY}/${OWNER}/${REPO}-rocm:${VERSION}-amd64" ] } @@ -137,7 +132,7 @@ group "cpu-all" { } group "gpu-all" { - targets = ["gpu", "gpu-amd64", "gpu-arm64"] + targets = ["gpu-amd64", "gpu-arm64"] } group "rocm-all" { @@ -145,7 +140,7 @@ group "rocm-all" { } group "all" { - targets = ["cpu", "gpu", "rocm"] + targets = ["cpu", "gpu-amd64", "gpu-arm64", "rocm-amd64"] } group "individual-platforms" { diff --git a/docker/gpu/Dockerfile.optimized b/docker/gpu/Dockerfile.optimized index 8f953c38..7c172e32 100644 --- a/docker/gpu/Dockerfile.optimized +++ b/docker/gpu/Dockerfile.optimized @@ -1,5 +1,9 @@ +# CUDA_VERSION is overridden per-arch in docker-bake.hcl (amd64=12.6.3, arm64=12.9.1). +# The arm64 bump is required because pytorch.org/whl/cu126 has no aarch64 wheels. +ARG CUDA_VERSION=12.6.3 + # Stage 1: Builder - Use devel image for compilation -FROM --platform=$BUILDPLATFORM nvcr.io/nvidia/cuda:12.6.3-cudnn-devel-ubuntu24.04 AS builder +FROM --platform=$BUILDPLATFORM nvcr.io/nvidia/cuda:${CUDA_VERSION}-cudnn-devel-ubuntu24.04 AS builder # Install Python and build dependencies RUN apt-get update -y && \ @@ -24,7 +28,7 @@ RUN uv venv --python 3.10 && \ uv sync --extra gpu --no-cache --no-install-project # Stage 2: Runtime - Use smaller runtime image -FROM --platform=$BUILDPLATFORM nvcr.io/nvidia/cuda:12.6.3-cudnn-runtime-ubuntu24.04 +FROM --platform=$BUILDPLATFORM nvcr.io/nvidia/cuda:${CUDA_VERSION}-cudnn-runtime-ubuntu24.04 # Install runtime dependencies + uv (needed by entrypoint.sh) RUN apt-get update -y && \ diff --git a/pyproject.toml b/pyproject.toml index 14b64cbe..77fcf573 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,10 @@ dependencies = [ ] [project.optional-dependencies] -gpu = ["torch==2.8.0+cu126"] +gpu = [ + "torch==2.8.0+cu126 ; platform_machine == 'x86_64'", + "torch==2.8.0+cu129 ; platform_machine == 'aarch64'", +] cpu = ["torch==2.8.0"] rocm = [ "torch==2.8.0+rocm6.4", @@ -73,7 +76,8 @@ override-dependencies = [ [tool.uv.sources] torch = [ { index = "pytorch-cpu", extra = "cpu" }, - { index = "pytorch-cuda", extra = "gpu" }, + { index = "pytorch-cu126", extra = "gpu", marker = "platform_machine == 'x86_64'" }, + { index = "pytorch-cu129", extra = "gpu", marker = "platform_machine == 'aarch64'" }, { index = "pytorch-rocm", extra = "rocm" }, ] pytorch-triton-rocm = [ @@ -86,10 +90,15 @@ url = "https://download.pytorch.org/whl/cpu" explicit = true [[tool.uv.index]] -name = "pytorch-cuda" +name = "pytorch-cu126" url = "https://download.pytorch.org/whl/cu126" explicit = true +[[tool.uv.index]] +name = "pytorch-cu129" +url = "https://download.pytorch.org/whl/cu129" +explicit = true + [[tool.uv.index]] name = "pytorch-rocm" url = "https://download.pytorch.org/whl/rocm6.4" From a10fc75da14ee0a8e5e157ae43249f9ae2561390 Mon Sep 17 00:00:00 2001 From: remsky Date: Thu, 14 May 2026 20:36:28 -0600 Subject: [PATCH 14/45] fix(web): firefox/supported playback handling and msging, bmac link, waveform bugfix ci: build caching, build: container naming consistency on local compose --- .github/workflows/release.yml | 4 +- .github/workflows/test_build.yml | 8 +++- VERSION | 2 +- docker/cpu/docker-compose.yml | 1 + docker/rocm/docker-compose.yml | 1 + web/index.html | 6 +++ web/src/App.js | 45 +++++++++++++++++- web/src/components/WaveVisualizer.js | 4 +- web/src/services/AudioService.js | 70 +++++++++++++++++----------- web/styles/base.css | 64 ++++++++++++++++++++++++- 10 files changed, 170 insertions(+), 35 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8468c53..dacc6272 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -136,7 +136,9 @@ jobs: TARGET="${BUILD_TARGET}-$(echo ${PLATFORM} | cut -d'/' -f2)" echo "Using bake target: $TARGET" - docker buildx bake $TARGET --push --progress=plain + docker buildx bake $TARGET --push --progress=plain \ + --set "*.cache-from=type=gha,scope=${TARGET}" \ + --set "*.cache-to=type=gha,mode=max,scope=${TARGET}" create-manifests: needs: [prepare-release, build-images] diff --git a/.github/workflows/test_build.yml b/.github/workflows/test_build.yml index d702ff65..998432bf 100644 --- a/.github/workflows/test_build.yml +++ b/.github/workflows/test_build.yml @@ -93,8 +93,12 @@ jobs: - name: Build image run: | TARGET="${{ matrix.build_target }}-${{ matrix.platform }}" + CACHE_ARGS=( + --set "*.cache-from=type=gha,scope=${TARGET}" + --set "*.cache-to=type=gha,mode=max,scope=${TARGET}" + ) if [[ "${{ inputs.dry_run }}" == "true" ]]; then - docker buildx bake "$TARGET" --print + docker buildx bake "$TARGET" --print "${CACHE_ARGS[@]}" else - docker buildx bake "$TARGET" --progress=plain + docker buildx bake "$TARGET" --progress=plain "${CACHE_ARGS[@]}" fi diff --git a/VERSION b/VERSION index 32513898..9325c3cc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0-rc \ No newline at end of file +0.3.0 \ No newline at end of file diff --git a/docker/cpu/docker-compose.yml b/docker/cpu/docker-compose.yml index f2fd9c8f..a8197066 100644 --- a/docker/cpu/docker-compose.yml +++ b/docker/cpu/docker-compose.yml @@ -1,6 +1,7 @@ name: kokoro-fastapi-cpu services: kokoro-tts: + # image: ghcr.io/remsky/kokoro-fastapi-cpu:v${VERSION} build: context: ../.. dockerfile: docker/cpu/Dockerfile.optimized diff --git a/docker/rocm/docker-compose.yml b/docker/rocm/docker-compose.yml index b569a8ad..3a59d5d0 100644 --- a/docker/rocm/docker-compose.yml +++ b/docker/rocm/docker-compose.yml @@ -1,6 +1,7 @@ name: kokoro-fastapi-rocm services: kokoro-tts: + # image: ghcr.io/remsky/kokoro-fastapi-rocm:v${VERSION} build: context: ../.. dockerfile: docker/rocm/Dockerfile diff --git a/web/index.html b/web/index.html index 3f9db97c..61808aa5 100644 --- a/web/index.html +++ b/web/index.html @@ -129,10 +129,16 @@

FastKoko

Cancel +
+
diff --git a/web/src/App.js b/web/src/App.js index 7ab3d7ff..7cf32e56 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -16,7 +16,8 @@ export class App { autoplayToggle: document.getElementById('autoplay-toggle'), formatSelect: document.getElementById('format-select'), status: document.getElementById('status'), - cancelBtn: document.getElementById('cancel-btn') + cancelBtn: document.getElementById('cancel-btn'), + streamingNotice: document.getElementById('streaming-notice') }; this.initialize(); @@ -53,6 +54,31 @@ export class App { this.setupEventListeners(); this.setupAudioEvents(); + this.applyBrowserStreamingNotice(); + } + + applyBrowserStreamingNotice() { + const notice = this.elements.streamingNotice; + if (!notice) { + return; + } + const format = this.elements.formatSelect?.value || 'mp3'; + const formatLabel = format.toUpperCase(); + const isFirefox = /Firefox\//.test(navigator.userAgent); + let message = ''; + + if (format === 'pcm') { + message = 'PCM output can be generated, but in-browser playback may be unsupported.'; + } else if (format !== 'mp3') { + message = `${formatLabel} output will be generated, playback and/or download will be available when generation finishes.`; + } else if (!this.audioService.supportsMSEMp3()) { + message = isFirefox + ? 'Audio streaming is not currently supported in Firefox. Playback and/or download should stilll be available when generation finishes.' + : 'This browser may not support streaming. Playback and/or download should still be available when generation finishes.'; + } + + notice.textContent = message; + notice.hidden = !message; } setupEventListeners() { @@ -62,6 +88,9 @@ export class App { // Download button this.elements.downloadBtn.addEventListener('click', () => this.downloadAudio()); + // Keep browser/output warning aligned with the selected format + this.elements.formatSelect.addEventListener('change', () => this.applyBrowserStreamingNotice()); + // Cancel button this.elements.cancelBtn.addEventListener('click', () => { this.audioService.cancel(); @@ -110,7 +139,9 @@ export class App { // Handle download ready this.audioService.addEventListener('downloadReady', () => { setTimeout(() => { - this.showStatus('Generation complete', 'success'); + if (!this._playbackFailed) { + this.showStatus('Generation complete', 'success'); + } }, 500); // Small delay to ensure "Preparing file..." is visible }); @@ -125,6 +156,15 @@ export class App { this.setGenerating(false); this.elements.downloadBtn.style.display = 'none'; }); + + // Block-mode playback failure: file is still available for download + this.audioService.addEventListener('playbackUnavailable', () => { + this._playbackFailed = true; + this.showStatus( + 'Playback unavailable in this browser. Use the download below.', + 'info' + ); + }); } showStatus(message, type = 'info') { @@ -170,6 +210,7 @@ export class App { const speed = this.playerState.getState().speed; this.setGenerating(true); + this._playbackFailed = false; this.elements.downloadBtn.classList.remove('ready'); // Just reset progress bar, don't do full cleanup diff --git a/web/src/components/WaveVisualizer.js b/web/src/components/WaveVisualizer.js index 15f756fc..b4df33d8 100644 --- a/web/src/components/WaveVisualizer.js +++ b/web/src/components/WaveVisualizer.js @@ -78,8 +78,8 @@ export class WaveVisualizer { cleanup() { if (this.wave) { - this.wave.stop(); - this.wave.dispose(); + if (typeof this.wave.stop === 'function') this.wave.stop(); + if (typeof this.wave.dispose === 'function') this.wave.dispose(); this.wave = null; } diff --git a/web/src/services/AudioService.js b/web/src/services/AudioService.js index 7d321cb9..0c1d7ace 100644 --- a/web/src/services/AudioService.js +++ b/web/src/services/AudioService.js @@ -25,13 +25,10 @@ export class AudioService { ); } - getPreferredStreamingMode() { - return this.supportsMSEMp3(); - } - async streamAudio(text, voice, speed, onProgress) { try { - console.log('AudioService: Starting stream...', { text, voice, speed }); + const canStreamMp3 = this.supportsMSEMp3(); + console.log('AudioService: Starting stream...', { text, voice, speed, canStreamMp3 }); if (this.controller) { this.controller.abort(); @@ -45,14 +42,8 @@ export class AudioService { this.shouldAutoplay = document.getElementById('autoplay-toggle').checked; const estimatedChunks = Math.max(1, Math.ceil(this.textLength / this.CHARS_PER_CHUNK)); - const canStreamMp3 = this.getPreferredStreamingMode(); - - console.log('AudioService: Making API call...', { - text, - voice, - speed, - canStreamMp3 - }); + const responseFormat = document.getElementById('format-select').value || 'mp3'; + const canUseMseStream = responseFormat === 'mp3' && canStreamMp3; const apiUrl = await config.getApiUrl('/v1/audio/speech'); const response = await fetch(apiUrl, { @@ -61,9 +52,9 @@ export class AudioService { body: JSON.stringify({ input: text, voice: voice, - response_format: 'mp3', - download_format: document.getElementById('format-select').value || 'mp3', - stream: canStreamMp3, + response_format: responseFormat, + download_format: responseFormat, + stream: true, speed: speed, return_download_link: true, lang_code: document.getElementById('lang-select').value || undefined @@ -88,7 +79,7 @@ export class AudioService { throw new Error(error.detail?.message || 'Failed to generate speech'); } - await this.setupAudioStream(response.body, response, onProgress, estimatedChunks); + await this.setupAudioStream(response.body, response, onProgress, estimatedChunks, canUseMseStream); return this.audio; } catch (error) { this.cleanup(); @@ -96,15 +87,43 @@ export class AudioService { } } - async setupBlobPlayback(response, onProgress) { - this.audio = new Audio(); + async setupBlockMode(stream, response, onProgress, estimatedChunks) { + const reader = stream.getReader(); + const chunks = []; + let receivedChunks = 0; - const blob = await response.blob(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + chunks.push(value); + receivedChunks++; + onProgress?.(receivedChunks, estimatedChunks); + } + } catch (error) { + if (error.name === 'AbortError') { + return; + } + throw error; + } + + const headers = Object.fromEntries(response.headers.entries()); + const downloadPath = headers['x-download-path']; + if (downloadPath) { + this.serverDownloadPath = await config.getApiUrl(`/v1${downloadPath}`); + } + + onProgress?.(estimatedChunks, estimatedChunks); + + const blobType = response.headers.get('content-type') || 'audio/mpeg'; + const blob = new Blob(chunks, { type: blobType }); + this.audio = new Audio(); this.objectUrl = URL.createObjectURL(blob); this.audio.src = this.objectUrl; this.audio.addEventListener('error', () => { - console.error('Audio error:', this.audio.error); + console.error('Audio error (block mode):', this.audio?.error); + this.dispatchEvent('playbackUnavailable'); }); this.audio.addEventListener('ended', () => { @@ -117,7 +136,6 @@ export class AudioService { } }, { once: true }); - onProgress?.(1, 1); this.dispatchEvent('complete'); setTimeout(() => { @@ -125,10 +143,10 @@ export class AudioService { }, 100); } - async setupAudioStream(stream, response, onProgress, estimatedChunks) { - if (!this.supportsMSEMp3()) { - console.warn('MSE audio/mpeg not supported in this browser. Falling back to blob playback.'); - await this.setupBlobPlayback(response, onProgress); + async setupAudioStream(stream, response, onProgress, estimatedChunks, canUseMseStream) { + if (!canUseMseStream) { + console.warn('MSE streaming unavailable for this output. Using block mode (full file then play).'); + await this.setupBlockMode(stream, response, onProgress, estimatedChunks); return; } diff --git a/web/styles/base.css b/web/styles/base.css index 052ad030..e986a8d3 100644 --- a/web/styles/base.css +++ b/web/styles/base.css @@ -33,6 +33,8 @@ body { width: 100%; max-width: 100vw; overflow-x: hidden; + display: flex; + flex-direction: column; } .overlay { @@ -72,7 +74,7 @@ body { flex-direction: column; box-sizing: border-box; padding: clamp(5rem, 8vh, 7rem) clamp(0.75rem, 2vw, 2rem) 2rem; - min-height: 100vh; + flex: 1; } @media (max-width: 768px) { @@ -120,3 +122,63 @@ main { border: 1px solid rgba(34, 197, 94, 0.2); opacity: 1; } + +.streaming-notice { + padding: 0.625rem 0.875rem; + border-radius: 0.5rem; + background: rgba(234, 179, 8, 0.08); + border: 1px solid rgba(234, 179, 8, 0.25); + color: var(--text-light, #b9bcc7); + font-size: 0.8125rem; + line-height: 1.4; + margin-bottom: 0.5rem; +} + +.streaming-notice[hidden] { + display: none; +} + +.page-footer { + width: 100%; + padding: clamp(0.5rem, 1vh, 0.75rem) clamp(1rem, 2vw, 2rem); + display: flex; + justify-content: flex-end; + align-items: center; + background: rgba(15, 23, 42, 0.6); + border-top: 1px solid rgba(99, 102, 241, 0.15); + min-height: clamp(2.75rem, 5vh, 3.5rem); + position: relative; + z-index: 1; +} + +.bmc-link { + display: inline-block; + line-height: 0; + opacity: 0.85; + transition: opacity 0.2s ease, transform 0.2s ease; + border-radius: 8px; +} + +.bmc-link:hover, +.bmc-link:focus-visible { + opacity: 1; + transform: translateY(-1px); + outline: none; +} + +.bmc-link img { + height: 28px; + width: auto; + display: block; +} + +@media (max-width: 768px) { + .page-footer { + padding: 0.5rem 0.75rem; + min-height: 2.5rem; + justify-content: center; + } + .bmc-link img { + height: 24px; + } +} From 4ecbfe4b059e380cc9e860506b05b6791ee9846f Mon Sep 17 00:00:00 2001 From: remsky Date: Thu, 14 May 2026 20:55:28 -0600 Subject: [PATCH 15/45] docs(readme): whisper validatons, version notes, feature bumps --- README.md | 66 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index f0dbfdca..b2d65c51 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,14 @@ [![Coverage](https://img.shields.io/badge/coverage-52%25-tan)]() [![Try on Spaces](https://img.shields.io/badge/%F0%9F%A4%97%20Try%20on-Spaces-blue)](https://huggingface.co/spaces/Remsky/Kokoro-TTS-Zero) -[![Kokoro](https://img.shields.io/badge/kokoro-0.9.2-BB5420)](https://github.com/hexgrad/kokoro) -[![Misaki](https://img.shields.io/badge/misaki-0.9.3-B8860B)](https://github.com/hexgrad/misaki) +[![Kokoro](https://img.shields.io/badge/kokoro-0.9.4-BB5420)](https://github.com/hexgrad/kokoro) +[![Misaki](https://img.shields.io/badge/misaki-0.9.4-B8860B)](https://github.com/hexgrad/misaki) [![Tested at Model Commit](https://img.shields.io/badge/last--tested--model--commit-1.0::9901c2b-blue)](https://huggingface.co/hexgrad/Kokoro-82M/commit/9901c2b79161b6e898b7ea857ae5298f47b8b0d6) Dockerized FastAPI wrapper for [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) text-to-speech model - Multi-language support (English, Japanese, Chinese, _Vietnamese soon_) -- OpenAI-compatible Speech endpoint, NVIDIA GPU accelerated or CPU inference with PyTorch +- OpenAI-compatible Speech endpoint with NVIDIA GPU, AMD GPU (ROCm, experimental), or CPU inference via PyTorch. Apple Silicon (MPS) supported when running directly via UV. - ONNX support coming soon, see v0.1.5 and earlier for legacy ONNX support in the interim - Debug endpoints for monitoring system stats, integrated web UI on localhost:8880/web - Phoneme-based audio generation, phoneme generation @@ -35,11 +35,12 @@ Refer to the core/config.py file for a full list of variables which can be manag ```bash # the `latest` tag can be used, though it may have some unexpected bonus features which impact stability. - Named versions should be pinned for your regular usage. - Feedback/testing is always welcome +### Named versions should be pinned for your regular usage. +### Feedback/testing is always welcome docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest # CPU, or: -docker run --gpus all -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-gpu:latest #NVIDIA GPU +docker run --gpus all -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-gpu:latest # NVIDIA GPU, or: +docker run --device=/dev/kfd --device=/dev/dri -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-rocm:latest # AMD GPU (ROCm, experimental, amd64 only) ``` @@ -56,14 +57,14 @@ docker run --gpus all -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-gpu:latest #NV git clone https://github.com/remsky/Kokoro-FastAPI.git cd Kokoro-FastAPI - cd docker/gpu # For GPU support - # or cd docker/cpu # For CPU support + cd docker/gpu # For NVIDIA GPU support + # or cd docker/cpu # For CPU support + # or cd docker/rocm # For AMD GPU (ROCm, experimental, amd64 only) docker compose up --build - # *Note for Apple Silicon (M1/M2) users: - # The current GPU build relies on CUDA, which is not supported on Apple Silicon. - # If you are on an M1/M2/M3 Mac, please use the `docker/cpu` setup. - # MPS (Apple's GPU acceleration) support is planned but not yet available. + # *Note for Apple Silicon (M1/M2/M3) users: + # The Docker GPU image is CUDA-only and won't run on Apple Silicon. With Docker, use `docker/cpu`. + # For native MPS (Apple GPU) acceleration, run directly via UV with `./start-gpu_mac.sh`. # Models will auto-download, but if needed you can manually download: python docker/scripts/download_model.py --output api/src/models/v1_0 @@ -354,11 +355,45 @@ Key Performance Metrics: - Realtime Speed: Ranges between 35x-100x (generation time to output audio length) - Average Processing Rate: 137.67 tokens/second (cl100k_base) + +
+Transcription Roundtrip (WER/CER) + +End-to-end roundtrip: synthesize with Kokoro, transcribe the result back with [`faster-whisper`](https://github.com/SYSTRAN/faster-whisper), compare to the source text. Scripts and data live under `examples/assorted_checks/test_transcription/`. + +**Long-form English** (full book, *A Journey to the Centre of the Earth*, Project Gutenberg, voice `af_heart`, `base.en` Whisper on CUDA float16, baseline captured on cu126 GPU build): + +| Run | Input chars | Audio length | Synth speedup | Transcribe speedup | WER | +| --- | --- | --- | --- | --- | --- | +| Short (~ch.7) | 64,996 | 66m 06s | 36.4x rt | 62.4x rt | **0.047** | +| Full book | 502,766 | 507m 52s | 45.7x rt | 65.1x rt | **0.033** | + +See `examples/assorted_checks/test_transcription/BASELINE.md` for the full regression bands. + +**Per-language check** (single-sentence per voice, multilingual Whisper `small`. WER for Latin scripts, CER for ja/zh/hi): + +| Language | Voice | Metric | Score | +| --- | --- | --- | --- | +| English | `af_heart` | WER | 0.000 | +| English (UK) | `bf_emma` | WER | 0.111 | +| Spanish | `ef_dora` | WER | 0.000 | +| French | `ff_siwis` | WER | 0.000 | +| Italian | `if_sara` | WER | 0.000 | +| Portuguese | `pf_dora` | WER | 0.000 | +| Hindi | `hf_alpha` | CER | 0.059 | +| Japanese | `jf_alpha` | CER | 0.000 | +| Chinese | `zf_xiaobei` | CER | 0.143 | + +*Caveat: these are single short sentences, not a comprehensive per-language quality benchmark. They confirm each voice produces transcribable audio in its target language; deeper quality evaluation per language is open work.* + +To reproduce, see `examples/assorted_checks/test_transcription/README.md`. +
+
GPU Vs. CPU ```bash -# GPU: Requires NVIDIA GPU with CUDA 12.6 support (~35x-100x realtime speed) +# GPU: Requires NVIDIA driver with CUDA 12.6+ support (~35x-100x realtime speed) cd docker/gpu docker compose up --build @@ -366,8 +401,11 @@ docker compose up --build cd docker/cpu docker compose up --build +# AMD GPU: ROCm 6.4 (experimental, amd64 only) +cd docker/rocm +docker compose up --build + ``` -*Note: Overall speed may have reduced somewhat with the structural changes to accommodate streaming. Looking into it*
From b51d455605b4e8e13a2f8bfb40beedf024accbfa Mon Sep 17 00:00:00 2001 From: remsky Date: Tue, 19 May 2026 14:51:04 -0600 Subject: [PATCH 16/45] Revise README with updated features and languages updated README / cleanup --- README.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index b2d65c51..40784d6f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # _`FastKoko`_ [![Tests](https://img.shields.io/badge/tests-81-darkgreen)]() [![Coverage](https://img.shields.io/badge/coverage-52%25-tan)]() -[![Try on Spaces](https://img.shields.io/badge/%F0%9F%A4%97%20Try%20on-Spaces-blue)](https://huggingface.co/spaces/Remsky/Kokoro-TTS-Zero) +[![Try on Spaces](https://img.shields.io/badge/%F0%9F%A4%97%20Try%20on-Spaces-blue)](https://huggingface.co/spaces/Remsky/FastKoko) [![Kokoro](https://img.shields.io/badge/kokoro-0.9.4-BB5420)](https://github.com/hexgrad/kokoro) [![Misaki](https://img.shields.io/badge/misaki-0.9.4-B8860B)](https://github.com/hexgrad/misaki) @@ -13,13 +13,15 @@ [![Tested at Model Commit](https://img.shields.io/badge/last--tested--model--commit-1.0::9901c2b-blue)](https://huggingface.co/hexgrad/Kokoro-82M/commit/9901c2b79161b6e898b7ea857ae5298f47b8b0d6) Dockerized FastAPI wrapper for [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) text-to-speech model -- Multi-language support (English, Japanese, Chinese, _Vietnamese soon_) -- OpenAI-compatible Speech endpoint with NVIDIA GPU, AMD GPU (ROCm, experimental), or CPU inference via PyTorch. Apple Silicon (MPS) supported when running directly via UV. -- ONNX support coming soon, see v0.1.5 and earlier for legacy ONNX support in the interim -- Debug endpoints for monitoring system stats, integrated web UI on localhost:8880/web -- Phoneme-based audio generation, phoneme generation -- Per-word timestamped caption generation -- Voice mixing with weighted combinations +- OpenAI-compatible Speech endpoint, multi-language support + - English (US/GB), Spanish, French, Hindi, Italian, Japanese, Brazilian Portuguese, Mandarin Chinese +- Per-word timestamped caption generation, voice mixing with weighted combinations +- Phoneme endpoints: generate phonemes from text, or generate audio from phonemes +- Prebuilt multiplatform images + - CPU and NVIDIA GPU (CUDA): linux/amd64 + linux/arm64 + - AMD GPU (ROCm, experimental): linux/amd64 only +- Apple Silicon (MPS) supported when running directly via UV (no image) + ### Integration Guides [![Helm Chart](https://img.shields.io/badge/Helm%20Chart-black?style=flat&logo=helm&logoColor=white)](https://github.com/remsky/Kokoro-FastAPI/wiki/Setup-Kubernetes) [![DigitalOcean](https://img.shields.io/badge/DigitalOcean-black?style=flat&logo=digitalocean&logoColor=white)](https://github.com/remsky/Kokoro-FastAPI/wiki/Integrations-DigitalOcean) [![SillyTavern](https://img.shields.io/badge/SillyTavern-black?style=flat&color=red)](https://github.com/remsky/Kokoro-FastAPI/wiki/Integrations-SillyTavern) @@ -546,7 +548,6 @@ Monitor system state and resource usage with these endpoints: - `/debug/threads` - Get thread information and stack traces - `/debug/storage` - Monitor temp file and output directory usage - `/debug/system` - Get system information (CPU, memory, GPU) -- `/debug/session_pools` - View ONNX session and CUDA stream status Useful for debugging resource exhaustion or performance issues.
@@ -585,7 +586,7 @@ $env:API_LOG_LEVEL = 'WARNING'
Missing words & Missing some timestamps -The api will automaticly do text normalization on input text which may incorrectly remove or change some phrases. This can be disabled by adding `"normalization_options":{"normalize": false}` to your request json: +The api will automatically do text normalization on input text which may incorrectly remove or change some phrases. This can be disabled by adding `"normalization_options":{"normalize": false}` to your request json: ```python import requests @@ -710,4 +711,4 @@ The full Apache 2.0 license text can be found at: https://www.apache.org/license -Made with [contrib.rocks](https://contrib.rocks). \ No newline at end of file +Made with [contrib.rocks](https://contrib.rocks). From 7bbd3a8e43380cf53fef8984395ff5c62c471b2f Mon Sep 17 00:00:00 2001 From: remsky Date: Thu, 21 May 2026 12:25:13 -0600 Subject: [PATCH 17/45] fix(mappings): /v1/audio/voices returns id/name objects (#462) to align with OpenWebUI --- api/src/core/openai_mappings.json | 2 +- api/src/routers/openai_compatible.py | 14 +++++++++++--- api/tests/test_openai_endpoints.py | 8 ++++++-- ui/lib/api.py | 3 ++- web/src/services/VoiceService.js | 2 +- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/api/src/core/openai_mappings.json b/api/src/core/openai_mappings.json index 4439d12c..5a1bc4af 100644 --- a/api/src/core/openai_mappings.json +++ b/api/src/core/openai_mappings.json @@ -12,7 +12,7 @@ "echo": "af_v0bella", "fable": "af_sarah", "onyx": "bm_george", - "nova": "bf_isabella", + "nova": "bf_v0isabella", "sage": "am_michael", "shimmer": "af_sky" } diff --git a/api/src/routers/openai_compatible.py b/api/src/routers/openai_compatible.py index 97d2b59f..c1292621 100644 --- a/api/src/routers/openai_compatible.py +++ b/api/src/routers/openai_compatible.py @@ -546,12 +546,20 @@ async def retrieve_model(model: str): @router.get("/audio/voices") -async def list_voices(): - """List all available voices for text-to-speech""" +async def list_voices(legacy: bool = False): + """List all available voices for text-to-speech. + + Returns `[{"id": ..., "name": ...}, ...]` by default so OpenAI-compatible + clients (Open WebUI in particular, which does `voice['id']` directly and + silently falls back to a hardcoded 6-voice list otherwise) can render the + full voice list. Pass `?legacy=true` for the pre-0.3.x plain-string shape. + """ try: tts_service = await get_tts_service() voices = await tts_service.list_voices() - return {"voices": voices} + if legacy: + return {"voices": voices} + return {"voices": [{"id": v, "name": v} for v in voices]} except Exception as e: logger.error(f"Error listing voices: {str(e)}") raise HTTPException( diff --git a/api/tests/test_openai_endpoints.py b/api/tests/test_openai_endpoints.py index 00e27d63..f682c9f1 100644 --- a/api/tests/test_openai_endpoints.py +++ b/api/tests/test_openai_endpoints.py @@ -402,8 +402,12 @@ def test_list_voices(mock_tts_service): data = response.json() assert "voices" in data assert len(data["voices"]) == 2 - assert "voice1" in data["voices"] - assert "voice2" in data["voices"] + assert {"id": "voice1", "name": "voice1"} in data["voices"] + assert {"id": "voice2", "name": "voice2"} in data["voices"] + + legacy = client.get("/v1/audio/voices?legacy=true") + assert legacy.status_code == 200 + assert legacy.json()["voices"] == ["voice1", "voice2"] @patch("api.src.routers.openai_compatible.settings") diff --git a/ui/lib/api.py b/ui/lib/api.py index 8bb8b87c..ae647417 100644 --- a/ui/lib/api.py +++ b/ui/lib/api.py @@ -16,7 +16,8 @@ def check_api_status() -> Tuple[bool, List[str]]: timeout=30, # Increased timeout for initial startup period ) response.raise_for_status() - voices = response.json().get("voices", []) + raw_voices = response.json().get("voices", []) + voices = [v["id"] if isinstance(v, dict) else v for v in raw_voices] if voices: return True, voices print("No voices found in response") diff --git a/web/src/services/VoiceService.js b/web/src/services/VoiceService.js index 94d3ee0d..362d0644 100644 --- a/web/src/services/VoiceService.js +++ b/web/src/services/VoiceService.js @@ -20,7 +20,7 @@ export class VoiceService { throw new Error('No voices available'); } - this.availableVoices = data.voices; + this.availableVoices = data.voices.map(v => typeof v === 'string' ? v : v.id); // Select first voice if none selected if (this.selectedVoices.size === 0) { From 0cf239ef55e4040a04bd84a5515ba055017246fd Mon Sep 17 00:00:00 2001 From: remsky Date: Thu, 21 May 2026 12:26:09 -0600 Subject: [PATCH 18/45] fix(docker): compose defaults to idempotent model download; harden download_model scripts, static release artifact link --- docker/cpu/docker-compose.yml | 1 + docker/gpu/docker-compose.yml | 1 + docker/scripts/download_model.py | 18 ++++++++++++++++-- docker/scripts/download_model.sh | 2 +- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docker/cpu/docker-compose.yml b/docker/cpu/docker-compose.yml index a8197066..a793ace9 100644 --- a/docker/cpu/docker-compose.yml +++ b/docker/cpu/docker-compose.yml @@ -19,6 +19,7 @@ services: - ONNX_MEMORY_PATTERN=true - ONNX_ARENA_EXTEND_STRATEGY=kNextPowerOfTwo - API_LOG_LEVEL=DEBUG + - DOWNLOAD_MODEL=true # # Gradio UI service [Comment out everything below if you don't need it] # gradio-ui: diff --git a/docker/gpu/docker-compose.yml b/docker/gpu/docker-compose.yml index d01a62e6..335a6e85 100644 --- a/docker/gpu/docker-compose.yml +++ b/docker/gpu/docker-compose.yml @@ -15,6 +15,7 @@ services: - USE_GPU=true - PYTHONUNBUFFERED=1 - API_LOG_LEVEL=DEBUG + - DOWNLOAD_MODEL=true deploy: resources: reservations: diff --git a/docker/scripts/download_model.py b/docker/scripts/download_model.py index 67a9409c..7a376b2f 100644 --- a/docker/scripts/download_model.py +++ b/docker/scripts/download_model.py @@ -3,10 +3,24 @@ import json import os +import sys from pathlib import Path from urllib.request import urlretrieve -from loguru import logger + +def _log(msg: str) -> None: + print(msg, file=sys.stderr, flush=True) + + +class _Logger: + def info(self, msg: str) -> None: + _log(msg) + + def error(self, msg: str) -> None: + _log(f"ERROR: {msg}") + + +logger = _Logger() def verify_files(model_path: str, config_path: str) -> bool: @@ -28,7 +42,7 @@ def verify_files(model_path: str, config_path: str) -> bool: # Verify config file is valid JSON with open(config_path) as f: - config = json.load(f) + json.load(f) # Check model file size (should be non-zero) if os.path.getsize(model_path) == 0: diff --git a/docker/scripts/download_model.sh b/docker/scripts/download_model.sh index 22166e37..a9ef7014 100644 --- a/docker/scripts/download_model.sh +++ b/docker/scripts/download_model.sh @@ -82,7 +82,7 @@ if verify_files "$MODEL_PATH" "$CONFIG_PATH"; then fi # Define URLs -BASE_URL="https://github.com/remsky/Kokoro-FastAPI/releases/download/v1.4" +BASE_URL="https://github.com/remsky/Kokoro-FastAPI/releases/download/v0.1.4" MODEL_URL="$BASE_URL/$MODEL_FILE" CONFIG_URL="$BASE_URL/$CONFIG_FILE" From 255e55937218d4a1301f8c28dfea86617101ff76 Mon Sep 17 00:00:00 2001 From: remsky Date: Fri, 22 May 2026 00:19:08 -0600 Subject: [PATCH 19/45] test: add integration suite + tts-api-test-client image; drop superseded cpu/gpu Dockerfiles --- .codeql-config.yml | 10 + .github/workflows/test_client_image.yml | 84 ++ .gitignore | 5 +- api/tests/integration/__init__.py | 0 api/tests/integration/conftest.py | 97 +++ api/tests/integration/test_tts_roundtrip.py | 162 ++++ api/tests/integration/test_voices_endpoint.py | 54 ++ api/tests/pytest.ini | 0 docker/cpu/Dockerfile | 66 -- docker/docker-compose.test.yml | 52 ++ docker/gpu/Dockerfile | 59 -- docker/test-client/Dockerfile | 50 ++ docker/test-client/pyproject.toml | 17 + docker/test-client/pytest.ini | 4 + docker/test-client/uv.lock | 718 ++++++++++++++++++ pyproject.toml | 4 + pytest.ini | 4 +- 17 files changed, 1259 insertions(+), 127 deletions(-) create mode 100644 .codeql-config.yml create mode 100644 .github/workflows/test_client_image.yml create mode 100644 api/tests/integration/__init__.py create mode 100644 api/tests/integration/conftest.py create mode 100644 api/tests/integration/test_tts_roundtrip.py create mode 100644 api/tests/integration/test_voices_endpoint.py create mode 100644 api/tests/pytest.ini delete mode 100644 docker/cpu/Dockerfile create mode 100644 docker/docker-compose.test.yml delete mode 100644 docker/gpu/Dockerfile create mode 100644 docker/test-client/Dockerfile create mode 100644 docker/test-client/pyproject.toml create mode 100644 docker/test-client/pytest.ini create mode 100644 docker/test-client/uv.lock diff --git a/.codeql-config.yml b/.codeql-config.yml new file mode 100644 index 00000000..b28c3bee --- /dev/null +++ b/.codeql-config.yml @@ -0,0 +1,10 @@ +paths: + - api + - ui +paths-ignore: + - .venv + - "**/node_modules" + - "**/__pycache__" + - examples + - docs + - "**/tests/**" diff --git a/.github/workflows/test_client_image.yml b/.github/workflows/test_client_image.yml new file mode 100644 index 00000000..bfa44be0 --- /dev/null +++ b/.github/workflows/test_client_image.yml @@ -0,0 +1,84 @@ +name: Build & Publish TTS API Test Client Image + +# Publishes the TTS API test client image to GHCR. +# +# This image bakes in faster-whisper + Whisper small CT2 weights and the +# audio-decoding stack (soundfile + libsndfile, pyav via faster-whisper) so +# that `docker-compose.test.yml` can run end-to-end protocol + transcription +# tests against a running server without touching Hugging Face at CI time. +# +# Lifecycle is independent of the app version. Rebuilt only when the +# Dockerfile or pinned dep set changes, or on manual dispatch. + +on: + workflow_dispatch: + inputs: + image_tag: + description: 'Tag to publish (also always re-tagged as `latest`)' + required: true + type: string + default: 'v1' + push: + description: 'Push to registry (uncheck for a dry build)' + required: false + type: boolean + default: true + push: + branches: [master, release] + paths: + - 'docker/test-client/**' + - '.github/workflows/test_client_image.yml' + +concurrency: + group: test-client-image-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-24.04 + permissions: + packages: write + contents: read + env: + REGISTRY: ${{ vars.REGISTRY || 'ghcr.io' }} + OWNER: ${{ vars.OWNER || 'remsky' }} + IMAGE_NAME: ${{ vars.TEST_CLIENT_IMAGE_NAME || 'tts-api-test-client' }} + steps: + - uses: actions/checkout@v4 + + - name: Resolve image refs + id: refs + run: | + BASE="${REGISTRY}/${OWNER}/${IMAGE_NAME}" + # Manual dispatch picks the tag; path-triggered builds always + # publish `latest` plus a short-sha tag for traceability. + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + PRIMARY="${BASE}:${{ inputs.image_tag }}" + else + PRIMARY="${BASE}:sha-${GITHUB_SHA:0:7}" + fi + echo "primary=${PRIMARY}" >> "$GITHUB_OUTPUT" + echo "latest=${BASE}:latest" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name != 'workflow_dispatch' || inputs.push + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: docker/test-client + platforms: linux/amd64 + push: ${{ github.event_name != 'workflow_dispatch' || inputs.push }} + tags: | + ${{ steps.refs.outputs.primary }} + ${{ steps.refs.outputs.latest }} + cache-from: type=gha,scope=test-client + cache-to: type=gha,mode=max,scope=test-client diff --git a/.gitignore b/.gitignore index 211f7d4f..3f2e9c4c 100644 --- a/.gitignore +++ b/.gitignore @@ -52,7 +52,6 @@ api/temp_files/ # Docker # Dockerfile* -docker-compose* examples/ebook_test/chapter_to_audio.py examples/ebook_test/chapters_to_audio.py examples/ebook_test/parse_epub.py @@ -78,8 +77,12 @@ examples/assorted_checks/test_transcription/output_long_form/*.wav examples/assorted_checks/test_transcription/output_long_form/*.synth_meta.json examples/assorted_checks/test_transcription/output_multilingual/*.wav uv.lock +!docker/test-client/uv.lock # Mac MPS virtualenv for dual testing .venv-mps examples/assorted_checks/test_transcription/*/*.wav pyproject.toml.bkp + +# Local scratch notes, scan outputs, anything not meant to ship +.local/ diff --git a/api/tests/integration/__init__.py b/api/tests/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py new file mode 100644 index 00000000..cfa021d8 --- /dev/null +++ b/api/tests/integration/conftest.py @@ -0,0 +1,97 @@ +"""Integration test fixtures. + +Designed to run inside the `tts-api-test-client` image against a networked +Kokoro server. The fixtures only set up clients; they do NOT manage server +lifecycle. + +Required env: + KOKORO_BASE_URL Base URL of a running Kokoro server. Inside compose + this is `http://server:8880`; for local non-Docker + runs, point at whatever you've got listening. + WHISPER_MODEL Filesystem path (or model name) for faster-whisper. + The test client image pre-bakes weights at + /opt/whisper/small and sets this for you. +""" + +from __future__ import annotations + +import os +import time +from collections.abc import Iterator + +import httpx +import pytest + + +SERVER_READY_TIMEOUT = float(os.environ.get("KOKORO_SERVER_READY_TIMEOUT", "120")) +HEALTH_POLL_INTERVAL = 1.0 + + +def _wait_for_health(url: str, timeout: float) -> None: + deadline = time.monotonic() + timeout + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + r = httpx.get(f"{url}/health", timeout=2.0) + if r.status_code == 200: + return + except Exception as exc: + last_error = exc + time.sleep(HEALTH_POLL_INTERVAL) + raise RuntimeError( + f"Server at {url} did not become healthy within {timeout}s " + f"(last error: {last_error!r})" + ) + + +@pytest.fixture(scope="session") +def server_url() -> Iterator[str]: + raw = os.environ.get("KOKORO_BASE_URL") + if not raw: + pytest.skip( + "KOKORO_BASE_URL not set. Run via `docker compose -f " + "docker-compose.test.yml up` or set the env var to a " + "running Kokoro server's base URL." + ) + base = raw.rstrip("/") + if base.endswith("/v1"): + base = base[:-3] + _wait_for_health(base, SERVER_READY_TIMEOUT) + yield base + + +@pytest.fixture(scope="session") +def openai_client(server_url: str): + import openai + + # Tight client-side timeout: healthy synth on a CPU runner is 1-3s. + # Anything past 30s means the server is wedged; fail fast rather than + # stall the whole sweep on one hung case. + return openai.OpenAI( + base_url=f"{server_url}/v1", + api_key="not-needed", + timeout=30, + ) + + +@pytest.fixture(scope="session") +def whisper_model(): + """Load faster-whisper once per session. + + Inside the test-client image, WHISPER_MODEL points at a pre-baked + directory so this never touches the network. For source-tree runs + you can set it to a model name (e.g. `small`) and faster-whisper + will fetch from Hugging Face on first use. + + WHISPER_DEVICE / WHISPER_COMPUTE override the defaults below. Default + is cpu/int8 inside the image; bumping to cuda/float16 is supported if + you mount in a GPU and a CUDA-enabled ctranslate2. + """ + from faster_whisper import WhisperModel + + model = os.environ.get("WHISPER_MODEL", "small") + device = os.environ.get("WHISPER_DEVICE", "cpu") + compute = os.environ.get( + "WHISPER_COMPUTE", "int8" if device == "cpu" else "float16" + ) + return WhisperModel(model, device=device, compute_type=compute) diff --git a/api/tests/integration/test_tts_roundtrip.py b/api/tests/integration/test_tts_roundtrip.py new file mode 100644 index 00000000..1256c1f3 --- /dev/null +++ b/api/tests/integration/test_tts_roundtrip.py @@ -0,0 +1,162 @@ +"""End-to-end smoke test: synthesize each language and transcribe back. + +For each (voice, language) case we: + 1. Call the OpenAI-compatible /v1/audio/speech endpoint against a live server + 2. Require the response to be non-empty (catches Kokoro's silent-fail mode) + 3. Run faster-whisper over the audio and require the score under a + deliberately generous threshold + +This is a smoke test, not a quality bar. WER/CER thresholds are loose on +purpose. The signal we want is "did the pipeline break for language X." +Tighter thresholds belong in a separate manual evaluation harness. +""" + +from __future__ import annotations + +import io +import sys +import time +import unicodedata +import wave +from dataclasses import dataclass + +import pytest + + +# Windows stdout defaults to cp1252; printing Devanagari/CJK reference strings +# or transcripts raises UnicodeEncodeError and surfaces as a fake test failure. +# Reconfigure once at module import so any -s output is safe. +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") +from jiwer import cer, wer +from jiwer.transforms import ( + Compose, + ReduceToListOfListOfWords, + RemoveMultipleSpaces, + RemovePunctuation, + Strip, + ToLowerCase, +) + + +pytestmark = pytest.mark.integration + + +@dataclass(frozen=True) +class Case: + voice: str + lang: str + text: str + + +# One short sentence per language. Keep these stable. Whisper handles them +# well in CPU/int8 with `small`, so a regression below the threshold means +# something actually broke in Kokoro, not Whisper drift. +CASES: list[Case] = [ + Case("af_heart", "en", "The quick brown fox jumps over the lazy dog."), + Case("bf_emma", "en", "The rain in Spain falls mainly on the plain."), + Case("ef_dora", "es", "El sol brilla en el cielo azul."), + Case("ff_siwis", "fr", "Le soleil brille dans le ciel bleu."), + Case("if_sara", "it", "Il gatto dorme sul tappeto rosso."), + Case("pf_dora", "pt", "O gato dorme no tapete vermelho."), + Case("hf_alpha", "hi", "आज मौसम बहुत अच्छा है।"), + Case("jf_alpha", "ja", "今日はとても良い天気です。"), + Case("zf_xiaobei", "zh", "今天天气非常好。"), +] + +# Hindi uses combining diacritics; CJK has no word boundaries. CER is the +# fair metric for any of these. Everything else gets WER. +CER_LANGS = {"hi", "ja", "zh"} + +WER_THRESHOLD = 0.25 +CER_THRESHOLD = 0.25 +MIN_AUDIO_BYTES = 1000 + + +_WORD_NORMALIZE = Compose([ + ToLowerCase(), + RemovePunctuation(), + RemoveMultipleSpaces(), + Strip(), + ReduceToListOfListOfWords(), +]) + + +def _strip_for_cer(s: str) -> str: + return "".join( + c for c in s + if not c.isspace() and not unicodedata.category(c).startswith("P") + ) + + +def _score(lang: str, reference: str, hypothesis: str) -> float: + if lang in CER_LANGS: + return cer(_strip_for_cer(reference), _strip_for_cer(hypothesis)) + return wer( + reference, + hypothesis, + reference_transform=_WORD_NORMALIZE, + hypothesis_transform=_WORD_NORMALIZE, + ) + + +def _wav_seconds(audio_bytes: bytes) -> float: + with wave.open(io.BytesIO(audio_bytes), "rb") as wf: + frames = wf.getnframes() + rate = wf.getframerate() + return frames / float(rate) if rate else 0.0 + + +@pytest.mark.parametrize( + "case", + CASES, + ids=lambda c: f"{c.voice}-{c.lang}", +) +def test_tts_roundtrip(case: Case, openai_client, whisper_model, tmp_path): + threshold = CER_THRESHOLD if case.lang in CER_LANGS else WER_THRESHOLD + metric = "CER" if case.lang in CER_LANGS else "WER" + + t0 = time.perf_counter() + response = openai_client.audio.speech.create( + model="tts-1", + voice=case.voice, + input=case.text, + response_format="wav", + ) + audio = response.content + synth_s = time.perf_counter() - t0 + + assert audio, f"empty response body for {case.voice}/{case.lang}" + assert len(audio) >= MIN_AUDIO_BYTES, ( + f"suspiciously small WAV for {case.voice}/{case.lang}: " + f"{len(audio)} bytes (server likely silent-failed)" + ) + + audio_path = tmp_path / f"{case.voice}_{case.lang}.wav" + audio_path.write_bytes(audio) + duration_s = _wav_seconds(audio) + assert duration_s > 0.2, ( + f"WAV header parsed but duration is only {duration_s:.3f}s " + f"for {case.voice}/{case.lang}" + ) + + t0 = time.perf_counter() + segments, _info = whisper_model.transcribe( + str(audio_path), beam_size=1, language=case.lang + ) + transcript = " ".join(seg.text for seg in segments).strip() + transcribe_s = time.perf_counter() - t0 + + score = _score(case.lang, case.text, transcript) + print( + f"[{case.voice}/{case.lang}] " + f"synth={synth_s:.2f}s transcribe={transcribe_s:.2f}s " + f"{metric}={score:.3f} (<{threshold}) " + f"heard={transcript!r}" + ) + assert score < threshold, ( + f"{metric} {score:.3f} exceeded threshold {threshold} " + f"for {case.voice}/{case.lang}. " + f"Expected: {case.text!r}. Heard: {transcript!r}." + ) diff --git a/api/tests/integration/test_voices_endpoint.py b/api/tests/integration/test_voices_endpoint.py new file mode 100644 index 00000000..dab6460a --- /dev/null +++ b/api/tests/integration/test_voices_endpoint.py @@ -0,0 +1,54 @@ +"""Conformance for /v1/audio/voices shape + the OpenAI short-name mappings. + +Default shape is `[{"id": ..., "name": ...}, ...]` for common +OpenAI-compatible clients (e.g. Open WebUI). `?legacy=true` preserves the +plain-string shape. +""" + +from __future__ import annotations + +import httpx +import pytest + + +pytestmark = pytest.mark.integration + + +def test_voices_default_shape(server_url: str): + r = httpx.get(f"{server_url}/v1/audio/voices", timeout=10.0) + r.raise_for_status() + voices = r.json()["voices"] + assert voices, "voice list is empty" + assert all(isinstance(v, dict) for v in voices), ( + "default shape must be list[dict] for Open WebUI compatibility" + ) + assert all(set(v.keys()) >= {"id", "name"} for v in voices), ( + "every entry needs id + name" + ) + assert all(v["id"] == v["name"] for v in voices), ( + "id and name should match for Kokoro voices" + ) + + +def test_voices_legacy_shape(server_url: str): + r = httpx.get(f"{server_url}/v1/audio/voices?legacy=true", timeout=10.0) + r.raise_for_status() + voices = r.json()["voices"] + assert voices, "legacy voice list is empty" + assert all(isinstance(v, str) for v in voices), ( + "?legacy=true must return plain strings" + ) + + +def test_nova_mapping_resolves(openai_client): + """OpenAI short-names in openai_mappings.json must point at real voices.""" + response = openai_client.audio.speech.create( + model="tts-1", + voice="nova", + input="Test.", + response_format="wav", + ) + assert response.content, "nova short-name produced empty audio" + assert len(response.content) >= 1000, ( + "nova returned suspiciously small WAV; mapping likely broken again" + ) diff --git a/api/tests/pytest.ini b/api/tests/pytest.ini new file mode 100644 index 00000000..e69de29b diff --git a/docker/cpu/Dockerfile b/docker/cpu/Dockerfile deleted file mode 100644 index 28d3ffc1..00000000 --- a/docker/cpu/Dockerfile +++ /dev/null @@ -1,66 +0,0 @@ -FROM python:3.10-slim - -# Install dependencies and check espeak location -# Rust is required to build sudachipy and pyopenjtalk-plus -RUN apt-get update -y && \ - apt-get install -y espeak-ng espeak-ng-data git libsndfile1 curl ffmpeg g++ cmake && \ - apt-get clean && rm -rf /var/lib/apt/lists/* && \ - mkdir -p /usr/share/espeak-ng-data && \ - ln -s /usr/lib/*/espeak-ng-data/* /usr/share/espeak-ng-data/ && \ - curl -LsSf https://astral.sh/uv/install.sh | sh && \ - mv /root/.local/bin/uv /usr/local/bin/ && \ - mv /root/.local/bin/uvx /usr/local/bin/ && \ - useradd -m -u 1000 appuser && \ - mkdir -p /app/api/src/models/v1_0 && \ - chown -R appuser:appuser /app - -USER appuser -WORKDIR /app - -# Install Rust for the non-root user so builds (e.g., sudachipy) succeed -RUN curl https://sh.rustup.rs -sSf | sh -s -- -y - -# Ensure Cargo and the Python venv are on PATH; extend HTTP timeouts for uv -ENV PATH="/home/appuser/.cargo/bin:/app/.venv/bin:$PATH" \ - UV_HTTP_TIMEOUT=120 \ - UV_HTTP_RETRIES=3 - -# Copy dependency files -COPY --chown=appuser:appuser pyproject.toml ./pyproject.toml - -# Install dependencies with CPU extras -RUN uv venv --python 3.10 && \ - uv sync --extra cpu --no-cache - -# Japanese support requires the UniDic dictionary (~526MB on disk) for fugashi/MeCab. -# Enabled by default; set --build-arg INCLUDE_JAPANESE=false to skip and shave the image. -# Keep true. Setting false silently breaks ja generation. -ARG INCLUDE_JAPANESE=true -RUN if [ "$INCLUDE_JAPANESE" = "true" ]; then \ - .venv/bin/python -m unidic download; \ - fi - -# Copy project files including models -COPY --chown=appuser:appuser api ./api -COPY --chown=appuser:appuser web ./web -COPY --chown=appuser:appuser docker/scripts/ ./ -RUN chmod +x ./entrypoint.sh - -# Set environment variables -ENV PYTHONUNBUFFERED=1 \ - PYTHONPATH=/app:/app/api \ - UV_LINK_MODE=copy \ - USE_GPU=false \ - PHONEMIZER_ESPEAK_PATH=/usr/bin \ - PHONEMIZER_ESPEAK_DATA=/usr/share/espeak-ng-data \ - ESPEAK_DATA_PATH=/usr/share/espeak-ng-data \ - DEVICE="cpu" - -ENV DOWNLOAD_MODEL=true -# Download model if enabled -RUN if [ "$DOWNLOAD_MODEL" = "true" ]; then \ - python download_model.py --output api/src/models/v1_0; \ - fi - -# Run FastAPI server through entrypoint.sh -CMD ["./entrypoint.sh"] diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml new file mode 100644 index 00000000..dbbfee11 --- /dev/null +++ b/docker/docker-compose.test.yml @@ -0,0 +1,52 @@ +name: kokoro-fastapi-test +# +# Test orchestration. Brings up the Kokoro server and the Whisper-equipped +# TTS API test client, runs `pytest -m integration` against the live HTTP +# API, and exits with the client container's status code. +# +# Run from the repo root: +# docker compose -f docker/docker-compose.test.yml up --build \ +# --abort-on-container-exit --exit-code-from test-client +# +# Against a published server image (e.g. release verification): +# SERVER_IMAGE=ghcr.io/remsky/kokoro-fastapi-cpu:v0.3.0 \ +# docker compose -f docker/docker-compose.test.yml up \ +# --abort-on-container-exit --exit-code-from test-client +# +# Against a published test-client image (skips local Whisper build): +# TEST_CLIENT_IMAGE=ghcr.io/remsky/tts-api-test-client:v1 \ +# docker compose -f docker/docker-compose.test.yml up \ +# --abort-on-container-exit --exit-code-from test-client +# +services: + server: + image: ${SERVER_IMAGE:-kokoro-fastapi-test-server:local} + build: + context: .. + dockerfile: ${SERVER_DOCKERFILE:-docker/cpu/Dockerfile.optimized} + ports: + - "8880:8880" + environment: + - API_LOG_LEVEL=INFO + - PYTHONPATH=/app:/app/api + healthcheck: + test: + - CMD-SHELL + - 'python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen(''http://localhost:8880/health'',timeout=2).status==200 else 1)"' + interval: 5s + timeout: 5s + retries: 60 + start_period: 30s + + test-client: + image: ${TEST_CLIENT_IMAGE:-tts-api-test-client:local} + build: + context: ./test-client + depends_on: + server: + condition: service_healthy + volumes: + - ../api/tests/integration:/tests/integration:ro + environment: + KOKORO_BASE_URL: http://server:8880 + WHISPER_MODEL: /opt/whisper/small diff --git a/docker/gpu/Dockerfile b/docker/gpu/Dockerfile deleted file mode 100644 index 2ae50ca9..00000000 --- a/docker/gpu/Dockerfile +++ /dev/null @@ -1,59 +0,0 @@ -FROM --platform=$BUILDPLATFORM nvcr.io/nvidia/cuda:12.6.3-cudnn-devel-ubuntu24.04 - -# Install Python and other dependencies -RUN apt-get update -y && \ - apt-get install -y python3.10 python3-venv espeak-ng espeak-ng-data git libsndfile1 curl ffmpeg g++ cmake && \ - apt-get clean && rm -rf /var/lib/apt/lists/* && \ - mkdir -p /usr/share/espeak-ng-data && \ - ln -s /usr/lib/*/espeak-ng-data/* /usr/share/espeak-ng-data/ && \ - curl -LsSf https://astral.sh/uv/install.sh | sh && \ - mv /root/.local/bin/uv /usr/local/bin/ && \ - mv /root/.local/bin/uvx /usr/local/bin/ && \ - useradd -m -u 1001 appuser && \ - mkdir -p /app/api/src/models/v1_0 && \ - chown -R appuser:appuser /app - -USER appuser -WORKDIR /app - -# Copy dependency files -COPY --chown=appuser:appuser pyproject.toml ./pyproject.toml - -# Install dependencies with GPU extras -RUN uv venv --python 3.10 && \ - uv sync --extra gpu --no-cache - -# Japanese support requires the UniDic dictionary (~526MB on disk) for fugashi/MeCab. -# Enabled by default; set --build-arg INCLUDE_JAPANESE=false to skip and shave the image. -# Keep true. Setting false silently breaks ja generation. -ARG INCLUDE_JAPANESE=true -RUN if [ "$INCLUDE_JAPANESE" = "true" ]; then \ - .venv/bin/python -m unidic download; \ - fi - -# Copy project files including models -COPY --chown=appuser:appuser api ./api -COPY --chown=appuser:appuser web ./web -COPY --chown=appuser:appuser docker/scripts/ ./ -RUN chmod +x ./entrypoint.sh - - -# Set all environment variables in one go -ENV PATH="/app/.venv/bin:$PATH" \ - PYTHONUNBUFFERED=1 \ - PYTHONPATH=/app:/app/api \ - UV_LINK_MODE=copy \ - USE_GPU=true \ - PHONEMIZER_ESPEAK_PATH=/usr/bin \ - PHONEMIZER_ESPEAK_DATA=/usr/share/espeak-ng-data \ - ESPEAK_DATA_PATH=/usr/share/espeak-ng-data \ - DEVICE="gpu" - -ENV DOWNLOAD_MODEL=true -# Download model if enabled -RUN if [ "$DOWNLOAD_MODEL" = "true" ]; then \ - python download_model.py --output api/src/models/v1_0; \ - fi - -# Run FastAPI server through entrypoint.sh -CMD ["./entrypoint.sh"] diff --git a/docker/test-client/Dockerfile b/docker/test-client/Dockerfile new file mode 100644 index 00000000..c9000df2 --- /dev/null +++ b/docker/test-client/Dockerfile @@ -0,0 +1,50 @@ +# TTS API test client image. +# +# A portable validator that drives a running OpenAI-compatible TTS server +# over its HTTP API and asserts the responses meet a documented contract: +# format-correct audio bytes, sensible streaming, valid phonemes, coherent +# word timestamps, and (for transcription tests) intelligible speech. +# +# Test code is NOT baked in. Mount the suite at /tests/integration and the +# entrypoint discovers it. This lets the image stay stable while suites +# iterate. Currently driven from api/tests/integration in this repo. +# +# Whisper weights ARE baked in (~470MB) so CI never depends on Hugging Face +# at test time. + +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_LINK_MODE=copy \ + UV_PROJECT_ENVIRONMENT=/opt/test-client/.venv \ + WHISPER_MODEL=/opt/whisper/small + +# libsndfile1 is required by `soundfile` for decoding flac/ogg/wav formats. +# faster-whisper's own pyav dependency handles mp3/opus/aac. curl fetches +# the prebaked Whisper weights and the uv installer. +RUN apt-get update \ + && apt-get install -y --no-install-recommends libsndfile1 curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && curl -LsSf https://astral.sh/uv/install.sh | sh \ + && mv /root/.local/bin/uv /usr/local/bin/uv \ + && mv /root/.local/bin/uvx /usr/local/bin/uvx + +WORKDIR /opt/test-client +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-install-project + +# Pre-fetch the Whisper small CT2 weights into a known path. Loading via +# directory path skips Hugging Face entirely at test time. +RUN mkdir -p /opt/whisper/small \ + && curl -fSL https://github.com/remsky/Kokoro-FastAPI/releases/download/v0.1.4/faster-whisper-small-ct2.tar.gz \ + | tar -xz -C /opt/whisper/small \ + && echo 'Whisper small weights extracted to /opt/whisper/small' + +# Put the venv bin first so `pytest` resolves to the locked one. +ENV PATH="/opt/test-client/.venv/bin:$PATH" + +WORKDIR /tests +COPY pytest.ini /tests/pytest.ini +ENTRYPOINT ["pytest", "-m", "integration", "-v", "--tb=short", "-s"] +CMD ["integration/"] diff --git a/docker/test-client/pyproject.toml b/docker/test-client/pyproject.toml new file mode 100644 index 00000000..6803d044 --- /dev/null +++ b/docker/test-client/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "tts-api-test-client" +version = "0" +description = "Test client for OpenAI-compatible TTS HTTP APIs. Drives a running server, decodes the audio, transcribes with faster-whisper, asserts contract + intelligibility." +requires-python = ">=3.11,<3.12" +dependencies = [ + "faster-whisper==1.2.1", + "jiwer==4.0.0", + "soundfile==0.13.1", + "pytest==8.3.5", + "pytest-asyncio==0.25.3", + "httpx==0.26.0", + "openai==2.37.0", +] + +[tool.uv] +package = false diff --git a/docker/test-client/pytest.ini b/docker/test-client/pytest.ini new file mode 100644 index 00000000..62dd1a8d --- /dev/null +++ b/docker/test-client/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +python_files = test_*.py +markers = + integration: end-to-end test against a running Kokoro server (this image's reason to exist). diff --git a/docker/test-client/uv.lock b/docker/test-client/uv.lock new file mode 100644 index 00000000..aca5774c --- /dev/null +++ b/docker/test-client/uv.lock @@ -0,0 +1,718 @@ +version = 1 +revision = 3 +requires-python = "==3.11.*" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "av" +version = "17.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/f0/8c8dca97ae0cf00e8e2a53bb5cb9aca5fd484f585ef3e9b412200aff3ebd/av-17.0.1.tar.gz", hash = "sha256:fbcbd4aa43bca6a8691816283112d1659a27f407bbeb66d1397023691339f5d4", size = 4411938, upload-time = "2026-04-18T17:12:34.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/82/e7007dcef7bd2d2c377e2e85977701384f42d19fc808c2ccb3a99eaf58f2/av-17.0.1-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:987f4f46ceae4da6c614dcbd2b8149be9dbf680c3bb7a6841c58af9cff4d9230", size = 23238802, upload-time = "2026-04-18T17:11:51.166Z" }, + { url = "https://files.pythonhosted.org/packages/6b/aa/858b09a08ea6f83f91be44b5a5adad13ae8d9ac8b80fda27e73c24bfb160/av-17.0.1-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:d97f54e55b18a74912f479c1978aadd1341d38d892dee95bb5c2f2dccfa72f32", size = 18709338, upload-time = "2026-04-18T17:11:53.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/8b/8de3fd21c4b0b74d44337421abeab0e71462337fb6a28fff888e0c356cbd/av-17.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e6eee84afa48d0e9321047cd3e4facd44b401493f6bdc753e2e1d1e7c9e6d13e", size = 34007351, upload-time = "2026-04-18T17:11:56.116Z" }, + { url = "https://files.pythonhosted.org/packages/02/28/167b291356c2cc315a2d62a95b0ceace72b5b0bf547de30b89313110f032/av-17.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c58c71bffd9383908c85695ac61d3184c668accb04a5bd1b262e0fb8d09f60a5", size = 36345295, upload-time = "2026-04-18T17:11:59.125Z" }, + { url = "https://files.pythonhosted.org/packages/04/fa/aae56f2ff2c204c408641e1120f5ca5ce9c3390cf5362245c6f1158704b5/av-17.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:42d6745d30a410ec9b22aef79a52a7ab5a001eb8f5adfd952946606a30983318", size = 35183754, upload-time = "2026-04-18T17:12:01.697Z" }, + { url = "https://files.pythonhosted.org/packages/ba/bd/776046f27093aef80155a204ca7d82a887ae4ee72ba4ef8411b46ea7898c/av-17.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3ed6bcd7021fe55832f95b8ef78dd01a4cb21faf3cd71f1e1bf4f20bf100b278", size = 37430809, upload-time = "2026-04-18T17:12:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d5/3261bd2c6b7f6c0aa8379fc970d1ecf496330990b992ad28607785074268/av-17.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:9af524e8632a54032e361d6b88895bd3e7c6212ca560de60f5ccc525323c764c", size = 28889649, upload-time = "2026-04-18T17:12:07.04Z" }, + { url = "https://files.pythonhosted.org/packages/98/39/381104e427a0c7231d2ec0d25d538d58fc20fc0458846b95860d3ef8073b/av-17.0.1-cp311-abi3-win_arm64.whl", hash = "sha256:50e58a473d65ea29b645e45c9fd8518a6783737135683ecc40571a91592bdfe4", size = 21918412, upload-time = "2026-04-18T17:12:09.312Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, +] + +[[package]] +name = "click" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "ctranslate2" +version = "4.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pyyaml" }, + { name = "setuptools" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/a3/8279b0d040f5db0569bafe0b89c984d54457cf0da0f65479714c523219a2/ctranslate2-4.7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fae4c008552832520876f708b05f6023b32f0d549606d39e22e925c1b97e17cc", size = 1259553, upload-time = "2026-05-19T06:41:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/08/3d/eb60218895baa71cb50640dceb29aae162549f325b9714b06c3dc0c26d17/ctranslate2-4.7.2-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:d942a2a069d653d14555a4c19033a15ab58d2302c7cb8c47e8795be75fb577e0", size = 11919986, upload-time = "2026-05-19T06:41:18.414Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/2bd99cd2f12e45c821ed9f4d7461b587bffbab791856ec83fcbccf722faf/ctranslate2-4.7.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48198d9e4dc8d7d86086e929b24c7595480e880ed242b07981ebbc4a7e6cd9a5", size = 16700271, upload-time = "2026-05-19T06:41:22.314Z" }, + { url = "https://files.pythonhosted.org/packages/e7/65/891ff8a6115f92a68edd280de0aa52f31308bfea08c356d4ba53a5b8d1ad/ctranslate2-4.7.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb2ba50abdf695c7b1991556af905a654234f5e05e79280518d0a2cfc8b22397", size = 38841486, upload-time = "2026-05-19T06:41:27.979Z" }, + { url = "https://files.pythonhosted.org/packages/8e/38/a57a845d141cc6cfae1beb65c67115fb88b1ed9cc850c377a1a76e94b407/ctranslate2-4.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:e77879fcaf90ce96f23a8981824bd1a08a3067589f80630442695faa6363d1d3", size = 18844431, upload-time = "2026-05-19T06:41:31.992Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "faster-whisper" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "av" }, + { name = "ctranslate2" }, + { name = "huggingface-hub" }, + { name = "onnxruntime" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/99/49ee85903dee060d9f08297b4a342e5e0bcfca2f027a07b4ee0a38ab13f9/faster_whisper-1.2.1-py3-none-any.whl", hash = "sha256:79a66ad50688c0b794dd501dc340a736992a6342f7f95e5811be60b5224a26a7", size = 1118909, upload-time = "2025-10-31T11:35:47.794Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/8d/1c51c094345df128ca4a990d633fe1a0ff28726c9e6b3c41ba65087bba1d/fsspec-2026.4.0.tar.gz", hash = "sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4", size = 312760, upload-time = "2026-04-29T20:42:38.635Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2", size = 203402, upload-time = "2026-04-29T20:42:36.842Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/d8/5c06fc76461418326a7decf8367480c35be11a41fd938633929c60a9ec6b/hf_xet-1.5.0.tar.gz", hash = "sha256:e0fb0a34d9f406eed88233e829a67ec016bec5af19e480eac65a233ea289a948", size = 837196, upload-time = "2026-05-06T06:18:15.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/fb/69ff198a82cae7eb1a69fb84d93b3a3e4816564d76817fe541ddc96874eb/hf_xet-1.5.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dad0dc84e941b8ba3c860659fe1fdc35c049d47cce293f003287757e971a8f56", size = 4030814, upload-time = "2026-05-06T06:17:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a", size = 3798444, upload-time = "2026-05-06T06:17:55.79Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949", size = 4465986, upload-time = "2026-05-06T06:17:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a2/546f47f464737b3edbab6f8ddb57f2599b93d2cbb66f06abb475ccb48651/hf_xet-1.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9a0ee58cd18d5ea799f7ed11290bbccbe56bdd8b1d97ca74b9cc49a3945d7a3b", size = 4259865, upload-time = "2026-05-06T06:17:42.639Z" }, + { url = "https://files.pythonhosted.org/packages/95/7f/1be593c1f28613be2e196473481cd81bfc5910795e30a34e8f744f6cac4f/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e60df5a42e9bed8628b6416af2cba4cba57ae9f02de226a06b020d98e1aab18", size = 4459835, upload-time = "2026-05-06T06:18:08.026Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b2/703569fc881f3284487e68cda7b42179978480da3c438042a6bbbb4a671c/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4b35549ce62601b84da4ff9b24d970032ace3d4430f52d91bcbb26c901d6c690", size = 4672414, upload-time = "2026-05-06T06:18:09.864Z" }, + { url = "https://files.pythonhosted.org/packages/af/37/1b6def445c567286b50aa3b33828158e135b1be44938dde59f11382a500c/hf_xet-1.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:2806c7c17b4d23f8d88f7c4814f838c3b6150773fe339c20af23e1cfaf2797e4", size = 3977238, upload-time = "2026-05-06T06:18:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/62/94/3b66b148778ee100dcfd69c2ca22b57b41b44d3063ceec934f209e9184ce/hf_xet-1.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:b6c9df403040248c76d808d3e047d64db2d923bae593eb244c41e425cf6cd7be", size = 3806916, upload-time = "2026-05-06T06:18:21.7Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/26/2dc654950920f499bd062a211071925533f821ccdca04fa0c2fd914d5d06/httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf", size = 125671, upload-time = "2023-12-20T11:02:58.032Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/9b/4937d841aee9c2c8102d9a4eeb800c7dad25386caabb4a1bf5010df81a57/httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd", size = 75862, upload-time = "2023-12-20T11:02:55.395Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/0f/ed994dbade67a54407c28cab96ef845e0e6d25500be56aca6394f8bfc9dd/huggingface_hub-1.16.1.tar.gz", hash = "sha256:7f1dc4c5ec21aed69be630ad0c3378616be16f3de1a47b141c0e812965d9c832", size = 792534, upload-time = "2026-05-21T18:40:00.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/79/621a7dbb80c70974f73a597275351ebe03ce5bc65cb5f8f4acb5859252bc/huggingface_hub-1.16.1-py3-none-any.whl", hash = "sha256:64340de934b9ce37857ef85a82de72f5629e8a270f9119eabb12bf495eb53c22", size = 668176, upload-time = "2026-05-21T18:39:58.596Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jiter" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/13/daa722f5765c393576f466378f9dfd29d77c9bed939e0688f96afa3601ea/jiter-0.15.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0f862193b8696249d22ec433e85fd2ab0ad9596bc3e45e6c0bc55e8aeba97be2", size = 310899, upload-time = "2026-05-19T10:07:12.89Z" }, + { url = "https://files.pythonhosted.org/packages/7f/82/2d2551829b082f4b6d82b9f939b031fb808a10aab1ec0664f82e150bb9a2/jiter-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1303d4d68a9b051ea90502402063ecf3807da00ad2affa19ca1ae3b90b3c5f67", size = 314963, upload-time = "2026-05-19T10:07:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0a/8b1a51466f7fe9f31dbe4bc7e0ca848674f9825e0f737b929b97e8c60aa7/jiter-0.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:392b8ab019e5502d08aff85c6272209c24bc2cbe706ea82a56368f524236614a", size = 341730, upload-time = "2026-05-19T10:07:15.869Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2a/e71dea19822e2e404e83992a08c1d6b9b617bb944f28c9c2fbd85d02c91e/jiter-0.15.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:773b6eb282ce11ee19f05f6b2d4404fa308e5bbd353b0b80a0262caad6db2cd7", size = 366214, upload-time = "2026-05-19T10:07:17.259Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/97e1fa539d124a509a00ab7f669289d1c1d236ecabf12948a18f16c91082/jiter-0.15.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2c0c44d569ce0f2850f5c926f8caeb5f245fbc84475aeb36efccc2103e6dbd", size = 459527, upload-time = "2026-05-19T10:07:18.741Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7a/4a68d331aef8cf2e2393c14a3aacb635c62aa86071b0229899fb5baaa907/jiter-0.15.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:032396229564bca02440396bd327710719f724f5e7b7e9f7a8eb3faa4a2c2281", size = 375451, upload-time = "2026-05-19T10:07:20.208Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/1c445c2b6f0e30a274dc8082e0c3c7825411cce80d726bccd697c98cc8d3/jiter-0.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d37768fce7f88dd2a8c6091f2325dea27d30d30d5c6e7a1c0f0af77723b708", size = 349428, upload-time = "2026-05-19T10:07:22.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/94/e20d38984fc17a636371bffd2ae0f698124fdc8e75ef969cd2da6ba7cea7/jiter-0.15.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2c9cb907439d20bd0c7d7565ca01ee52234203208433749bae5b516907526928", size = 355405, upload-time = "2026-05-19T10:07:23.916Z" }, + { url = "https://files.pythonhosted.org/packages/94/fa/4d09f814779d0ea80a28ed8e4c6662ec9a4a8ecef0ac52190ebac6262d14/jiter-0.15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9100ddbec09741cc66feb0fc6773f8bdbd0e3c345689368f260082ff85dcc0cd", size = 393688, upload-time = "2026-05-19T10:07:25.854Z" }, + { url = "https://files.pythonhosted.org/packages/54/9d/8eb5d4fb8bf7e93a75964a5da71a75c67c864baf7fa3f98598187b3c7e57/jiter-0.15.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ae1b0d82ac2d987f9ea512b1c9adfcc71a28de3dea3a6039b54d76cffda9901e", size = 520853, upload-time = "2026-05-19T10:07:27.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/5e07874e59e623a943a0acf1552a80d05b70f31b402287a8fc6d7ec634c7/jiter-0.15.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8020c99ec13a7db2b6f96cbe82ef4721c88b426a4892f27478044af0284615ef", size = 551016, upload-time = "2026-05-19T10:07:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/d2d34422143474cadc15b60d482b1c35683dbc5c63c24346ddd0df09bcaf/jiter-0.15.0-cp311-cp311-win32.whl", hash = "sha256:42bfb257930800cf43e7c62c832402c704ab60797c992faf88d20e903eac8f32", size = 209518, upload-time = "2026-05-19T10:07:30.431Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7d/52778b930e5cc3e52a37d950b1c10494244308b4329b25a0ff0d88303a81/jiter-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:860a74063284a2ae9bfedd694f299cc2c68e2696c5f3d440cc9d18bb81b9dd04", size = 200565, upload-time = "2026-05-19T10:07:32.125Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4f/d9b4067feb69b3fa6eb0488e1b59e2ad5b463fe39f59e527eab2aca00bb0/jiter-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:37a10c377ce3a4a85f4a67f28b7afe093154cde77eaf248a72e856aa08b4d865", size = 195488, upload-time = "2026-05-19T10:07:33.846Z" }, + { url = "https://files.pythonhosted.org/packages/65/43/1fc62172aa98b50a7de9a25554060db510f85c89cfbed0dfe13e1907a139/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:411fa4dfa5a7ae3d11491027ffb9beadec3996010a986862db70d91abba1c750", size = 305585, upload-time = "2026-05-19T10:09:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c4/dd58fcd9e2df83666e5c1c1347bef58ce919cd8efc3ffa38aeea62ce493b/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:2b0074e2f56eb2dacca1689760fd2852a068f85a0547a157b82cb4cafeb6768b", size = 306936, upload-time = "2026-05-19T10:09:37.435Z" }, + { url = "https://files.pythonhosted.org/packages/39/86/b695e16f1180c07f43ea98e73ecd21cf63fa2e1b0c1103739013784d11ae/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:913d02d29c9606643418d9ccfc3b72492ab25a6bf7889934e09a3490f8d3438b", size = 342453, upload-time = "2026-05-19T10:09:39.294Z" }, + { url = "https://files.pythonhosted.org/packages/34/56/55d76614af37fe3f22a3347d1e410d2a15da581997cb2da499a625000bb5/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b15d3ec9b0449c40e85319bdb4caa8b77ab526e74f5532ed94bec15e2f66822c", size = 345606, upload-time = "2026-05-19T10:09:40.727Z" }, +] + +[[package]] +name = "jiwer" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rapidfuzz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/1e/963dfc249d5bbe88079b57a22556e981ddd9208e4b6116e48bdbfc01f26b/jiwer-4.0.0.tar.gz", hash = "sha256:ae9c051469102a61ef0927100baeeb4546f78d180c9b0948281d08eaf44c191e", size = 28074, upload-time = "2025-06-19T16:05:23.004Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/c9/172c525330c739a068c01050759a6f855ce16212db10a0359e690a03ac48/jiwer-4.0.0-py3-none-any.whl", hash = "sha256:7efaf0bd336b095d99ddef9dd67e1ee829d75d58aa2a81d9639870b01d6d95ea", size = 23034, upload-time = "2025-06-19T16:05:21.821Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/81/29a9eb470994a75eb7b3ccf32be314d7c66675a00ac7b50294816cc2db27/onnxruntime-1.26.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ee1109ef4ef27cad90e823399e61e03b3c6c7bfe0fb820b4baf3678c15be8b3c", size = 18005108, upload-time = "2026-05-08T19:08:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/66/c7/73efa6c8a4000c38fcc14947d84f234a17e5d66f203b37b7f1ad4a7b46eb/onnxruntime-1.26.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35c7c7b0ac2e02001d28fab6c9fc24e9abc5e6faa35e6e19c63cecf1406ba89f", size = 16043752, upload-time = "2026-05-08T19:07:10.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3f/8de630f595daf6ce884d4dd95afd2a60e70ec6572e52bfee3aa2229befab/onnxruntime-1.26.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11a8df4dcfe9ad5ff0bd71a7571dbed019fabc7594676c89fe8b86ea029c246f", size = 18176043, upload-time = "2026-05-08T19:07:33.735Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/9f041de20787cd85498bd48e0ec4d098bf2a6c486e25b24b8dae1bf492b2/onnxruntime-1.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:e6456718125fd777c673f3b78d4a9ab58d6adea641e9afae85ee6444f0e0e9a9", size = 13023165, upload-time = "2026-05-08T19:08:00.633Z" }, + { url = "https://files.pythonhosted.org/packages/0e/82/3b9fe0ead2557cc3adf74c74c141bd1c7c4c6a9548c610af37df199f4512/onnxruntime-1.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:cd920e45b730e4a87833e2910d8ca375aaca9da6ccc09e24bce463b3356d637f", size = 12789514, upload-time = "2026-05-08T19:07:49.433Z" }, +] + +[[package]] +name = "openai" +version = "2.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/50/5901f01ef14e6c27788beb91e54fef5d6204fb5fb9e97402fc8a14de2e32/openai-2.37.0.tar.gz", hash = "sha256:f4bc562cc5f3a43d40d678105572d9d44765f6e0f50c125f63055419b72f4bd9", size = 754706, upload-time = "2026-05-15T22:30:35.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/4c/bce61680d0699a78a405fd9a67989b175ba020590428831aab2ab1d2be7c/openai-2.37.0-py3-none-any.whl", hash = "sha256:814633888b8f3b1ffd6615697c6e4ef93632d08b7c2e28c8c5ef3556e5a10107", size = 1303238, upload-time = "2026-05-15T22:30:32.767Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/fd/5b1491d9e4b586d621c54f4c36b888714164b6875f8d6afa3f9072906a51/protobuf-7.35.0.tar.gz", hash = "sha256:a2efd84605f41e559f1881b0912b44099d0a2ac9bf46b3474823f10fb393b0e6", size = 458677, upload-time = "2026-05-19T23:02:29.197Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ee/93d06e358a4aa32280b00e722d3ea0a1f25fc3cc5778d80581c9cca2c10e/protobuf-7.35.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:66be6c513931c794fa92c080ffee41671390da3d79da219cf9c0c0907f035dda", size = 433225, upload-time = "2026-05-19T23:02:19.884Z" }, + { url = "https://files.pythonhosted.org/packages/8b/39/1c76c2da93f3c507e958e0aecee2391cc44d4625de6c728bbc555195b5a8/protobuf-7.35.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:fcbe42a4ac09d3ec9c987ddfcd956afd0b15f1ff613bd8371bde9405ffd5c8e5", size = 328847, upload-time = "2026-05-19T23:02:22.3Z" }, + { url = "https://files.pythonhosted.org/packages/91/1a/39f7ce90a238c1a987a4d81ec26379e02ca0aff367de68e4a1fa474215b9/protobuf-7.35.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:4cbf5cc286130e06a6c9bbefac442431173906dfcc979712183d4adcc01b37ee", size = 344030, upload-time = "2026-05-19T23:02:23.591Z" }, + { url = "https://files.pythonhosted.org/packages/70/5b/6baf9008817964454055ff3fe65f1de0b5f1e26c80c82f7fb108b7cd4ea3/protobuf-7.35.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:6c0f98f10c8a05ea30f8993dfef2de093d27b490fdae78bb60c8343795d55011", size = 327130, upload-time = "2026-05-19T23:02:24.637Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e5/e46adb0badc388bfb84877a5f9f026aff63f60e611016cf64dbe77e05446/protobuf-7.35.0-cp310-abi3-win32.whl", hash = "sha256:4c4617b83ade0e279d1d2bfe04025a1adb87f9ed657de038620dc0ff959357f6", size = 428946, upload-time = "2026-05-19T23:02:25.741Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ab/547fbd9e16d879dd13c167478f8ae0a83a428008ca07a5e06acdc23ad473/protobuf-7.35.0-cp310-abi3-win_amd64.whl", hash = "sha256:f05bcadf9a2a6b8dda047007075135fb7d08c73d9177aabc067e1be46881a201", size = 439996, upload-time = "2026-05-19T23:02:26.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ef/50433d346c56657a70d27f156c7b349ac59a068b01de4eb796e747eecc43/protobuf-7.35.0-py3-none-any.whl", hash = "sha256:c13f325cf242bad135c350629eeb5d54b24228eb472fb3e2e9ebbd4c5dc20ca0", size = 171659, upload-time = "2026-05-19T23:02:27.842Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/a8/ecbc8ede70921dd2f544ab1cadd3ff3bf842af27f87bbdea774c7baa1d38/pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a", size = 54239, upload-time = "2025-01-28T18:37:58.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467, upload-time = "2025-01-28T18:37:56.798Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, +] + +[[package]] +name = "rapidfuzz" +version = "3.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/f9/3c41a7be8855803f4f6c713b472226a98d31d41869d98f64f4ca790510d6/rapidfuzz-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e251126d48615e1f02b4a178f2cd0cd4f0332b8a019c01a2e10480f7552554b4", size = 1952372, upload-time = "2026-04-07T11:13:58.32Z" }, + { url = "https://files.pythonhosted.org/packages/9e/89/c2557e37531d03465193bff0ab9de70b468420a807d71a26a65100635459/rapidfuzz-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ab449c9abd0d4e1f8145dce0798a4c822a1a1933d613c764a641bea88b8bdab", size = 1159782, upload-time = "2026-04-07T11:14:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b2/ffeeb7eca1a897d51b998f4c0ef0281696c3b06abcca4f88f9def708ffe1/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb2829fedd672dd7107267189dabe2bbe07972801d636014417c6861eb89e358", size = 1383677, upload-time = "2026-04-07T11:14:01.696Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d0/4539e42a2d596e068f7738f279638a4a74edd1fbb6f8594e2458058979c6/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d50e5861872935fece391351cbb5ba21d1bced277cf5e1143d207a0a35f1925", size = 3168906, upload-time = "2026-04-07T11:14:03.29Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1c/3ec897eb9d8b05308aa8ef6ae4ed64b088ad521a3f9d8ff469e7e97bc2b0/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:7092a216728f80c960bd6b3807275d1ee318b168986bd5dc523349581d4890b8", size = 1478176, upload-time = "2026-04-07T11:14:04.94Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ba/970c03a12ce20a5399e22afe9f8932fd4cd1265b8a8461d0e63b00eb4eae/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9669753caef7fdc6529f6adcc5883ed98d65976445d9322e7dbdb6b697feee13", size = 2402441, upload-time = "2026-04-07T11:14:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/81/93/61d351cae60c1d0e21ba5ff1a1015ad045539ed215da9d6e302204ed887a/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:823b1b9d9230809d8edcc18872770764bfe8ef4357995e16744047c8ccf0e489", size = 2511628, upload-time = "2026-04-07T11:14:09.234Z" }, + { url = "https://files.pythonhosted.org/packages/87/52/374d2d4f60fd98155142a869323aa221e30868cfa1f15171a0f64070c247/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f0b2af76b7e7060c09e1a0dfa9410eb19369cbe6164509bff2ef94094b54d2b6", size = 4275480, upload-time = "2026-04-07T11:14:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/d8/04/82e7989bc9ec20a15b720a335c5cb6b0724bf6582013898f90a3280cfccd/rapidfuzz-3.14.5-cp311-cp311-win32.whl", hash = "sha256:c5801a89604c65ab4cc9e91b23bc4076d0ca80efd8c976fb63843d7879a85d7f", size = 1725627, upload-time = "2026-04-07T11:14:13.217Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b5/eca8ac5609bc9bcb02bb6ff87fa5983cc92b8772d66a431556ab8a8c178f/rapidfuzz-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:d7ca16637c0ede8243f84074044bd0b2335a0341421f8227c85756de2d18c819", size = 1545977, upload-time = "2026-04-07T11:14:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e1/dbf318de28f65fa2cdd0a9dfbdee380f8199eb83b19259bc4f8592551b4e/rapidfuzz-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:8c90cdf8516d9057e502aa6003cea71cf5ec27cc44699ca52412b502a04761bb", size = 816827, upload-time = "2026-04-07T11:14:16.788Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ee/e71853bf82846c5c2174b924b71d8e8099fb05ff87c958a720380b434ba3/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:578e6051f6d5e6200c259b47a103cf06bb875ab5814d17333fc0b5c290b22f4c", size = 1888603, upload-time = "2026-04-07T11:16:18.223Z" }, + { url = "https://files.pythonhosted.org/packages/36/82/40f67b730f32be2ebad9f62add1571c754f52249254b2e88af094b907eee/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbf1b8bb2695415b347f3727da1addca2acb82c9b97ac86bebf8b1bead1eb12d", size = 1120599, upload-time = "2026-04-07T11:16:20.682Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9f/a3635cc4ec8fc6e14b46e7db1f7f8763d8c4bef33dcc124eea2e6cb2c8f3/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4a8f5cc84c7ad6bffa0e9947b33eb343ad66e6b53e94fe54378a5508c5ed53", size = 1348524, upload-time = "2026-04-07T11:16:23.451Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1b/2b229520f0b48464cfcd7aa758f74551d12c9bc4ab544022a60210aab064/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c6d85283629646fa87acc22c66b30ea9d4de7f6fdf887daa2e30fa041829b5", size = 3099302, upload-time = "2026-04-07T11:16:25.858Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889, upload-time = "2026-04-07T11:16:28.487Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "soundfile" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/41/9b873a8c055582859b239be17902a85339bec6a30ad162f98c9b0288a2cc/soundfile-0.13.1.tar.gz", hash = "sha256:b2c68dab1e30297317080a5b43df57e302584c49e2942defdde0acccc53f0e5b", size = 46156, upload-time = "2025-01-25T09:17:04.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/28/e2a36573ccbcf3d57c00626a21fe51989380636e821b341d36ccca0c1c3a/soundfile-0.13.1-py2.py3-none-any.whl", hash = "sha256:a23c717560da2cf4c7b5ae1142514e0fd82d6bbd9dfc93a50423447142f2c445", size = 25751, upload-time = "2025-01-25T09:16:44.235Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ab/73e97a5b3cc46bba7ff8650a1504348fa1863a6f9d57d7001c6b67c5f20e/soundfile-0.13.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:82dc664d19831933fe59adad199bf3945ad06d84bc111a5b4c0d3089a5b9ec33", size = 1142250, upload-time = "2025-01-25T09:16:47.583Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e5/58fd1a8d7b26fc113af244f966ee3aecf03cb9293cb935daaddc1e455e18/soundfile-0.13.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:743f12c12c4054921e15736c6be09ac26b3b3d603aef6fd69f9dde68748f2593", size = 1101406, upload-time = "2025-01-25T09:16:49.662Z" }, + { url = "https://files.pythonhosted.org/packages/58/ae/c0e4a53d77cf6e9a04179535766b3321b0b9ced5f70522e4caf9329f0046/soundfile-0.13.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9c9e855f5a4d06ce4213f31918653ab7de0c5a8d8107cd2427e44b42df547deb", size = 1235729, upload-time = "2025-01-25T09:16:53.018Z" }, + { url = "https://files.pythonhosted.org/packages/57/5e/70bdd9579b35003a489fc850b5047beeda26328053ebadc1fb60f320f7db/soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:03267c4e493315294834a0870f31dbb3b28a95561b80b134f0bd3cf2d5f0e618", size = 1313646, upload-time = "2025-01-25T09:16:54.872Z" }, + { url = "https://files.pythonhosted.org/packages/fe/df/8c11dc4dfceda14e3003bb81a0d0edcaaf0796dd7b4f826ea3e532146bba/soundfile-0.13.1-py2.py3-none-win32.whl", hash = "sha256:c734564fab7c5ddf8e9be5bf70bab68042cd17e9c214c06e365e20d64f9a69d5", size = 899881, upload-time = "2025-01-25T09:16:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/14/e9/6b761de83277f2f02ded7e7ea6f07828ec78e4b229b80e4ca55dd205b9dc/soundfile-0.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:1e70a05a0626524a69e9f0f4dd2ec174b4e9567f4d8b6c11d38b5c289be36ee9", size = 1019162, upload-time = "2025-01-25T09:16:59.573Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748, upload-time = "2026-04-27T14:43:25.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/39/b87a87d5bb9470610b80a2d31df42fcffeaf35118b8b97952b2aff598cc7/tokenizers-0.23.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224", size = 3146732, upload-time = "2026-04-27T14:43:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/068ed9f6e444c9d7e9d55ce134181325700f3d7f30410721bdc8f848d727/tokenizers-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e", size = 3054954, upload-time = "2026-04-27T14:43:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/6c/36/e006edf031154cba92b8416057d92c3abe3635e4c4b0aa0b5b9bb39dde70/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3", size = 3374081, upload-time = "2026-04-27T14:43:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ef/7735d226f9c7f874a6bee5e3f27fb25ecabdf207d37b8cf45286d0795893/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6", size = 3247641, upload-time = "2026-04-27T14:43:03.856Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d9/24827036f6e21297bfffda0768e58eb6096a4f411e932964a01707857931/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959", size = 3585624, upload-time = "2026-04-27T14:43:10.664Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/22f3582b3a4f49358293a5206e25317621ee4526bfe9cdaa0f07a12e770e/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51", size = 3844062, upload-time = "2026-04-27T14:43:05.643Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/b8f8814eef95800f20721384136d9a1d22241d50b2874357cb70542c392f/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a", size = 3460098, upload-time = "2026-04-27T14:43:08.854Z" }, + { url = "https://files.pythonhosted.org/packages/0d/d5/1353e5f677ec27c2494fb6a6725e82d56c985f53e90ec511369e7e4f02c6/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4", size = 3346235, upload-time = "2026-04-27T14:43:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/39b6b8fc073fb6d413d0147aa333dc7eff7be65639ac9d19930a0b21bf33/tokenizers-0.23.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a", size = 3426398, upload-time = "2026-04-27T14:43:07.264Z" }, + { url = "https://files.pythonhosted.org/packages/0f/80/127c854da64827e5b79264ce524993a90dddcb320e5cd42412c5c02f9e8a/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e", size = 9823279, upload-time = "2026-04-27T14:43:17.222Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ba/44c2502feb1a058f096ddfb4e0996ef3225a01a388e1a9b094e91689fe93/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288", size = 9644986, upload-time = "2026-04-27T14:43:19.45Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c1/464019a9fb059870bfe4eebb4ba12208f3042035e258bf5e782906bd3847/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4", size = 9976181, upload-time = "2026-04-27T14:43:21.463Z" }, + { url = "https://files.pythonhosted.org/packages/79/94/3ac1432bda31626071e9b6a12709b97ae05131c804b94c8f3ac622c5da32/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96", size = 10113853, upload-time = "2026-04-27T14:43:23.617Z" }, + { url = "https://files.pythonhosted.org/packages/6a/dd/631b21433c771b1382535326f0eca80b9c9cee2e64961dd993bc9ac4669e/tokenizers-0.23.1-cp310-abi3-win32.whl", hash = "sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948", size = 2536263, upload-time = "2026-04-27T14:43:29.888Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/2553f72aaf65a2797d4229e37fa7fbe38ffbf3e32912d31bdd78b3323e59/tokenizers-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7", size = 2798223, upload-time = "2026-04-27T14:43:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2b/2be299bab55fc595e3d38567edb1a87f86e594842968fa9515a07bdcf422/tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9", size = 2664127, upload-time = "2026-04-27T14:43:26.949Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "tts-api-test-client" +version = "0" +source = { virtual = "." } +dependencies = [ + { name = "faster-whisper" }, + { name = "httpx" }, + { name = "jiwer" }, + { name = "openai" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "soundfile" }, +] + +[package.metadata] +requires-dist = [ + { name = "faster-whisper", specifier = "==1.2.1" }, + { name = "httpx", specifier = "==0.26.0" }, + { name = "jiwer", specifier = "==4.0.0" }, + { name = "openai", specifier = "==2.37.0" }, + { name = "pytest", specifier = "==8.3.5" }, + { name = "pytest-asyncio", specifier = "==0.25.3" }, + { name = "soundfile", specifier = "==0.13.1" }, +] + +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] diff --git a/pyproject.toml b/pyproject.toml index 77fcf573..581fdffd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,10 @@ test = [ "tomli>=2.0.1", "jinja2>=3.1.6", ] +# Integration test deps (faster-whisper, jiwer, soundfile, etc.) intentionally +# live in the tts-api-test-client image, not here. Run via: +# docker compose -f docker/docker-compose.test.yml up --build \ +# --abort-on-container-exit --exit-code-from test-client [tool.uv] conflicts = [ diff --git a/pytest.ini b/pytest.ini index 3bcd461b..5ebb50eb 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,7 @@ [pytest] testpaths = api/tests python_files = test_*.py -addopts = -v --tb=short --cov=api --cov-report=term-missing --cov-config=.coveragerc +addopts = -v --tb=short --cov=api --cov-report=term-missing --cov-config=.coveragerc -m "not integration" pythonpath = . +markers = + integration: end-to-end test that requires a running TTS server and downloads a Whisper model. Excluded by default; opt in with `pytest -m integration`. From 8e1bafe4be2f6d7e97843ebf8046519895d6b789 Mon Sep 17 00:00:00 2001 From: remsky Date: Fri, 22 May 2026 00:19:53 -0600 Subject: [PATCH 20/45] feat(web): show server version badge in the UI footer --- web/index.html | 1 + web/src/App.js | 17 +++++++++++++++++ web/src/config.js | 4 ++++ web/styles/badges.css | 22 ++++++++++++++++++++++ 4 files changed, 44 insertions(+) diff --git a/web/index.html b/web/index.html index 61808aa5..8abe74ab 100644 --- a/web/index.html +++ b/web/index.html @@ -139,6 +139,7 @@

FastKoko

Buy me a coffee + diff --git a/web/src/App.js b/web/src/App.js index 7cf32e56..9a83f192 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -5,6 +5,7 @@ import PlayerControls from './components/PlayerControls.js'; import VoiceSelector from './components/VoiceSelector.js'; import WaveVisualizer from './components/WaveVisualizer.js'; import TextEditor from './components/TextEditor.js'; +import config from './config.js'; export class App { constructor() { @@ -29,6 +30,8 @@ export class App { this.audioService = new AudioService(); this.voiceService = new VoiceService(); + this.renderVersionBadge(); + // Initialize components this.playerControls = new PlayerControls(this.audioService, this.playerState); this.voiceSelector = new VoiceSelector(this.voiceService); @@ -57,6 +60,20 @@ export class App { this.applyBrowserStreamingNotice(); } + async renderVersionBadge() { + const badge = document.getElementById('version-badge'); + if (!badge) return; + try { + await config.ensureInitialized(); + if (config.version) { + badge.textContent = `v${config.version}`; + badge.hidden = false; + } + } catch (_) { + // leave hidden on failure + } + } + applyBrowserStreamingNotice() { const notice = this.elements.streamingNotice; if (!notice) { diff --git a/web/src/config.js b/web/src/config.js index 00357fde..aad87403 100644 --- a/web/src/config.js +++ b/web/src/config.js @@ -6,6 +6,7 @@ class Config { constructor() { this.rootPath = ''; + this.version = ''; this.initialized = false; this.initPromise = this.initialize(); } @@ -26,6 +27,9 @@ class Config { this.rootPath = serverConfig.root_path.replace(/\/$/, ''); console.log('Config loaded from server. Root path:', this.rootPath); } + if (serverConfig.version !== undefined) { + this.version = serverConfig.version; + } } else { console.log('Using detected root path:', this.rootPath); } diff --git a/web/styles/badges.css b/web/styles/badges.css index f5f73edb..31386e04 100644 --- a/web/styles/badges.css +++ b/web/styles/badges.css @@ -75,3 +75,25 @@ height: 100%; border-radius: 4px; } + +.version-badge { + position: fixed; + bottom: 0.75rem; + left: 0.75rem; + z-index: 100; + padding: 0.25rem 0.55rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.75rem; + color: rgba(226, 232, 240, 0.7); + background: rgba(15, 23, 42, 0.7); + border: 1px solid rgba(99, 102, 241, 0.25); + border-radius: 999px; + text-decoration: none; + backdrop-filter: blur(8px); + transition: color 0.15s ease, border-color 0.15s ease; +} + +.version-badge:hover { + color: rgba(226, 232, 240, 1); + border-color: rgba(99, 102, 241, 0.6); +} From 4fca67d87980ea38e7a17fc6f260a93625e01746 Mon Sep 17 00:00:00 2001 From: remsky Date: Fri, 22 May 2026 00:24:10 -0600 Subject: [PATCH 21/45] chore: CHANGELOG, version candidate, examples requests bump --- CHANGELOG.md | 100 +++++++++++++++++++++++++++++++++++----- README.md | 8 ++-- VERSION | 2 +- api/src/core/config.py | 15 +++++- examples/pyproject.toml | 1 + 5 files changed, 109 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c076c282..1a6343f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,36 +2,114 @@ Notable changes to this project will be documented in this file. -## [v0.3.0] - 2025-04-04 +Per-PR attribution and contributor credits are published automatically on the corresponding GitHub release page; this file is the curated, human-readable summary. + +## [v0.3.1] - Unreleased +### Fixed +- `/v1/audio/voices` now returns `[{"id": ..., "name": ...}, ...]` by default so OpenAI-compatible clients (notably Open WebUI, which reads `voice['id']` and silently falls back to a hardcoded 6-voice list otherwise) can render the full voice catalog (#462). Pass `?legacy=true` to get the pre-0.3.1 plain-string shape. +- cpu/gpu composes set `DOWNLOAD_MODEL=true`, which runs an idempotent fetch from the static release artifact on startup. `download_model.py` also runs on stock Python for the host fallback. + +## [v0.3.0] - 2026-05-15 +### Added +- AMD GPU support via ROCm (`docker/rocm/` build, `rocm` extra in `pyproject.toml`). +- `gpt-4o-mini-tts` model alias for OpenAI-compatible clients. +- Reverse-proxy support for the Web UI (new `/config` endpoint exposing `UVICORN_ROOT_PATH`). +- Configurable logging level via the `API_LOG_LEVEL` environment variable. +- `INCLUDE_JAPANESE` Docker build flag for opt-in Japanese support. +- Transcription accuracy test harness under `examples/assorted_checks/test_transcription/` (baselines, multilingual reports, long-form runner). +- Override of `docker-bake.hcl` variables through GitHub Actions environment variables. + +### Changed +- PyTorch bumped to 2.8.0 (x86_64: cu126, aarch64: cu129). x86_64 settled on cu126 to keep Maxwell/Pascal cards working, which drops native Blackwell (RTX 50-series) kernel support. Blackwell users need to override the torch index manually. See #443. +- `kokoro` bumped to 0.9.4 and `misaki` to 0.9.4. +- New optimized multi-stage Dockerfiles (`docker/{cpu,gpu}/Dockerfile.optimized`) become the default bake target. Reported image sizes: CPU 5.6 → 4.9 GB, GPU 14.8 → 9.9 GB. +- Parallelized Docker bake targets per architecture for faster CI. +- ROCBlas version pinned; ROCm docker-compose now builds locally. +- CI/release workflow hardening: pinned BuildKit/runners, branch-tagged builds, manifest fixes, `workflow_dispatch` ref and tag-check race fixed, `latest` tag gated. + +### Fixed +- OGG/Opus audio truncation where the final page was lost during `write_chunk` finalize. +- Voice tensor loading hardened with `weights_only=True` (avoids unsafe pickle in `torch.load`). +- Per-request voice-tensor memory leak resolved via caching (#453), with cache cleared on unload. +- Custom phoneme handling made significantly more robust. +- Firefox Web UI playback falls back gracefully when `audio/mpeg` MSE is unsupported; waveform rendering bugfix bundled in the same web rewrite. +- CPU Docker builds: Rust now installed for `appuser` with proper `PATH` and longer `uv` timeouts. +- `cmake` added to CI deps to unblock `pyopenjtalk` builds. +- `start-gpu.sh` uses `#!/usr/bin/env bash` for broader compatibility. +- Apple Silicon: `test_initial_state()` no longer fails. + +## [v0.2.4] - 2025-06-18 ### Added - Apple Silicon (MPS) acceleration support for macOS users. - Voice subtraction capability for creating unique voice effects. - Windows PowerShell start scripts (`start-cpu.ps1`, `start-gpu.ps1`). - Automatic model downloading integrated into all start scripts. - Example Helm chart values for Azure AKS and Nvidia GPU Operator deployments. +- Volume multiplier setting. +- Chinese punctuation-based sentence splitting. - `CONTRIBUTING.md` guidelines for developers. ### Changed -- Version bump of underlying Kokoro and Misaki libraries +- Version bump of underlying Kokoro and Misaki libraries. - Default API port reverted to 8880. -- Docker containers now run as a non-root user for enhanced security. +- Docker containers now run as a non-root user. - Improved text normalization for numbers, currency, and time formats. +- Improved MP3 encoding and audio-pause handling. - Updated and improved Helm chart configurations and documentation. - Enhanced temporary file management with better error tracking. - Web UI dependencies (Siriwave) are now served locally. - Standardized environment variable handling across shell/PowerShell scripts. +- Rust installed in Dockerfile for builds requiring it. ### Fixed -- Corrected an issue preventing download links from being returned when `streaming=false`. -- Resolved errors in Windows PowerShell scripts related to virtual environment activation order. -- Addressed potential segfaults during inference. -- Fixed various Helm chart issues related to health checks, ingress, and default values. -- Corrected audio quality degradation caused by incorrect bitrate settings in some cases. -- Ensured custom phonemes provided in input text are preserved. -- Fixed a 'MediaSource' error affecting playback stability in the web player. +- Download links no longer dropped when `streaming=false` and `return_download_link=true`. +- Windows PowerShell start scripts fixed around virtual-environment activation order. +- Potential segfaults during inference addressed. +- Helm chart issues around health checks, ingress, and default values. +- Audio-quality degradation from incorrect bitrate settings in some paths. +- Custom phonemes provided in input text are now preserved end-to-end. +- 'MediaSource' error affecting playback stability in the web player. +- CRLF line endings in `custom_responses.py` converted to LF. +- Money parsing and related tests. +- Additional safety checks on captioned-speech generation. +- Phoneme handling fixes. ### Removed -- Obsolete GitHub Actions build workflow, build and publish now occurs on merge to `Release` branch +- Obsolete GitHub Actions build workflow; build and publish now occurs on merge to `Release` branch. + +## [v0.2.3] - 2025-03-06 +### Added +- Streaming word timestamps. +- `.gitattributes` for consistent line endings. + +### Changed +- Text normalization improvements. + +### Fixed +- Audio-quality regression caused by lower-bitrate encoding. +- Disabled uvicorn/FastAPI `--reload` to avoid pegging a CPU core. + +## [v0.2.2] - 2025-02-13 +### Added +- Helm chart. +- Settings-based override of the default `lang_code`. +- Advanced normalization settings. + +### Fixed +- Speech not engaging reliably on the CPU image fallback. +- Audio quality bumped via adjusted compression settings. +- Web UI format-selection bug. + +## [v0.2.1] - 2025-02-10 +### Added +- Dummy `/v1/models` endpoint for OpenAI compatibility (#144). + +### Changed +- Caption flow now streams audio with tempfile download at completion, removing duplicate captions (#139). + +### Fixed +- Compatibility with the `espeak-loader` dependency on misaki (#127). +- Build system and model-download issues. ## [v0.2.0post1] - 2025-02-07 - Fix: Building Kokoro from source with adjustments, to avoid CUDA lock diff --git a/README.md b/README.md index b2d65c51..c13a124b 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,12 @@ # _`FastKoko`_ [![Tests](https://img.shields.io/badge/tests-81-darkgreen)]() [![Coverage](https://img.shields.io/badge/coverage-52%25-tan)]() -[![Try on Spaces](https://img.shields.io/badge/%F0%9F%A4%97%20Try%20on-Spaces-blue)](https://huggingface.co/spaces/Remsky/Kokoro-TTS-Zero) +[![Changelog](https://img.shields.io/badge/changelog-white)](./CHANGELOG.md) [![Kokoro](https://img.shields.io/badge/kokoro-0.9.4-BB5420)](https://github.com/hexgrad/kokoro) [![Misaki](https://img.shields.io/badge/misaki-0.9.4-B8860B)](https://github.com/hexgrad/misaki) -[![Tested at Model Commit](https://img.shields.io/badge/last--tested--model--commit-1.0::9901c2b-blue)](https://huggingface.co/hexgrad/Kokoro-82M/commit/9901c2b79161b6e898b7ea857ae5298f47b8b0d6) +[![Tested at Model Commit](https://img.shields.io/badge/last--tested--model--commit-1.0::9901c2b-blue)](https://huggingface.co/hexgrad/Kokoro-82M/commit/9901c2b79161b6e898b7ea857ae5298f47b8b0d6) [![Try on Spaces](https://img.shields.io/badge/%F0%9F%A4%97%20Try%20on-Spaces-blue)](https://huggingface.co/spaces/Remsky/Kokoro-TTS-Zero) Dockerized FastAPI wrapper for [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) text-to-speech model - Multi-language support (English, Japanese, Chinese, _Vietnamese soon_) @@ -161,7 +161,7 @@ import requests response = requests.get("http://localhost:8880/v1/audio/voices") -voices = response.json()["voices"] +voices = [v["id"] for v in response.json()["voices"]] # Generate audio response = requests.post( @@ -199,7 +199,7 @@ Combine voices and generate audio: ```python import requests response = requests.get("http://localhost:8880/v1/audio/voices") -voices = response.json()["voices"] +voices = [v["id"] for v in response.json()["voices"]] # Example 1: Simple voice combination (50%/50% mix) response = requests.post( diff --git a/VERSION b/VERSION index 9325c3cc..8403f23f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0 \ No newline at end of file +0.3.1-rc1 \ No newline at end of file diff --git a/api/src/core/config.py b/api/src/core/config.py index 87edce02..56b6e68c 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -1,12 +1,25 @@ +from importlib.metadata import PackageNotFoundError, version as _pkg_version +from pathlib import Path + import torch from pydantic_settings import BaseSettings +def _read_version() -> str: + version_file = Path(__file__).resolve().parents[3] / "VERSION" + if version_file.exists(): + return version_file.read_text().strip() + try: + return _pkg_version("kokoro-fastapi") + except PackageNotFoundError: + return "0.0.0" + + class Settings(BaseSettings): # API Settings api_title: str = "Kokoro TTS API" api_description: str = "API for text-to-speech generation using Kokoro" - api_version: str = "1.0.0" + api_version: str = _read_version() host: str = "0.0.0.0" port: int = 8880 diff --git a/examples/pyproject.toml b/examples/pyproject.toml index 19caaebc..f223379a 100644 --- a/examples/pyproject.toml +++ b/examples/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "scipy>=1.10", "loguru>=0.7", "tqdm>=4.65", + "requests>=2.33.1", ] [project.optional-dependencies] From 3803f865460ace3bb1d1f599c52ea712fcd8bcd7 Mon Sep 17 00:00:00 2001 From: remsky Date: Fri, 22 May 2026 00:27:41 -0600 Subject: [PATCH 22/45] docs: fix badge links in README.md --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 65678e3d..ff15595a 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,13 @@ # _`FastKoko`_ [![Tests](https://img.shields.io/badge/tests-81-darkgreen)]() -[![Coverage](https://img.shields.io/badge/coverage-52%25-tan)]() -[![Changelog](https://img.shields.io/badge/changelog-white)](./CHANGELOG.md) -[![Try on Spaces](https://img.shields.io/badge/%F0%9F%A4%97%20Try%20on-Spaces-blue)](https://huggingface.co/spaces/Remsky/FastKoko) +[![Coverage](https://img.shields.io/badge/coverage-52%25-tan)]() [![Changelog](https://img.shields.io/badge/changelog-white)](./CHANGELOG.md) [![Kokoro](https://img.shields.io/badge/kokoro-0.9.4-BB5420)](https://github.com/hexgrad/kokoro) [![Misaki](https://img.shields.io/badge/misaki-0.9.4-B8860B)](https://github.com/hexgrad/misaki) -[![Tested at Model Commit](https://img.shields.io/badge/last--tested--model--commit-1.0::9901c2b-blue)](https://huggingface.co/hexgrad/Kokoro-82M/commit/9901c2b79161b6e898b7ea857ae5298f47b8b0d6) [![Try on Spaces](https://img.shields.io/badge/%F0%9F%A4%97%20Try%20on-Spaces-blue)](https://huggingface.co/spaces/Remsky/Kokoro-TTS-Zero) +[![Tested at Model Commit](https://img.shields.io/badge/last--tested--model--commit-1.0::9901c2b-blue)](https://huggingface.co/hexgrad/Kokoro-82M/commit/9901c2b79161b6e898b7ea857ae5298f47b8b0d6) [![Try on Spaces](https://img.shields.io/badge/%F0%9F%A4%97%20Try%20on-Spaces-blue)](https://huggingface.co/spaces/Remsky/FastKoko) + Dockerized FastAPI wrapper for [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) text-to-speech model - OpenAI-compatible Speech endpoint, multi-language support From 94f729f8bfb5f4fc238da23a7e9fe78487cdead5 Mon Sep 17 00:00:00 2001 From: remsky Date: Fri, 22 May 2026 00:34:27 -0600 Subject: [PATCH 23/45] fix(tests): lazy import integration reqs, exclude on CI --- api/tests/integration/test_tts_roundtrip.py | 43 +++++++++++---------- api/tests/pytest.ini | 0 2 files changed, 23 insertions(+), 20 deletions(-) delete mode 100644 api/tests/pytest.ini diff --git a/api/tests/integration/test_tts_roundtrip.py b/api/tests/integration/test_tts_roundtrip.py index 1256c1f3..6a9ce9a3 100644 --- a/api/tests/integration/test_tts_roundtrip.py +++ b/api/tests/integration/test_tts_roundtrip.py @@ -29,15 +29,6 @@ if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stderr.reconfigure(encoding="utf-8", errors="replace") -from jiwer import cer, wer -from jiwer.transforms import ( - Compose, - ReduceToListOfListOfWords, - RemoveMultipleSpaces, - RemovePunctuation, - Strip, - ToLowerCase, -) pytestmark = pytest.mark.integration @@ -74,15 +65,6 @@ class Case: MIN_AUDIO_BYTES = 1000 -_WORD_NORMALIZE = Compose([ - ToLowerCase(), - RemovePunctuation(), - RemoveMultipleSpaces(), - Strip(), - ReduceToListOfListOfWords(), -]) - - def _strip_for_cer(s: str) -> str: return "".join( c for c in s @@ -91,13 +73,34 @@ def _strip_for_cer(s: str) -> str: def _score(lang: str, reference: str, hypothesis: str) -> float: + # jiwer ships only in the tts-api-test-client image, not the base test + # env. Import it lazily so the default `pytest -m "not integration"` run + # can still collect this module (the marker filter runs after import). + from jiwer import cer, wer + from jiwer.transforms import ( + Compose, + ReduceToListOfListOfWords, + RemoveMultipleSpaces, + RemovePunctuation, + Strip, + ToLowerCase, + ) + if lang in CER_LANGS: return cer(_strip_for_cer(reference), _strip_for_cer(hypothesis)) + + word_normalize = Compose([ + ToLowerCase(), + RemovePunctuation(), + RemoveMultipleSpaces(), + Strip(), + ReduceToListOfListOfWords(), + ]) return wer( reference, hypothesis, - reference_transform=_WORD_NORMALIZE, - hypothesis_transform=_WORD_NORMALIZE, + reference_transform=word_normalize, + hypothesis_transform=word_normalize, ) diff --git a/api/tests/pytest.ini b/api/tests/pytest.ini deleted file mode 100644 index e69de29b..00000000 From c6398b98ab20128f387179452b048b212140613f Mon Sep 17 00:00:00 2001 From: remsky Date: Fri, 22 May 2026 00:43:32 -0600 Subject: [PATCH 24/45] fix(ci): run the test job with the cpu extra --- .github/workflows/ci.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da61e870..ff10e18e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,9 +34,6 @@ jobs: with: python-version: ${{ matrix.python-version }} enable-cache: true - - name: Install dependencies - run: | - uv pip install -e .[test,cpu] - name: Run Tests run: | - uv run pytest api/tests/ --asyncio-mode=auto --cov=api --cov-report=term-missing + uv run --extra test --extra cpu pytest api/tests/ --asyncio-mode=auto --cov=api --cov-report=term-missing From 64cb068438229fa49b5de13bc5fe109a288b1fb3 Mon Sep 17 00:00:00 2001 From: remsky Date: Fri, 22 May 2026 00:44:25 -0600 Subject: [PATCH 25/45] fix: int16 overflow in silence-trim, invalid-escape in URL regex --- api/src/services/audio.py | 4 ++-- api/src/services/text_processing/normalizer.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/src/services/audio.py b/api/src/services/audio.py index 6ae6d791..d8432d53 100644 --- a/api/src/services/audio.py +++ b/api/src/services/audio.py @@ -80,12 +80,12 @@ def find_first_last_non_silent( non_silent_index_start, non_silent_index_end = None, None for X in range(0, len(audio_data)): - if abs(audio_data[X]) > amplitude_threshold: + if abs(int(audio_data[X])) > amplitude_threshold: non_silent_index_start = X break for X in range(len(audio_data) - 1, -1, -1): - if abs(audio_data[X]) > amplitude_threshold: + if abs(int(audio_data[X])) > amplitude_threshold: non_silent_index_end = X break diff --git a/api/src/services/text_processing/normalizer.py b/api/src/services/text_processing/normalizer.py index 1b1c9f7e..aaacf329 100644 --- a/api/src/services/text_processing/normalizer.py +++ b/api/src/services/text_processing/normalizer.py @@ -160,7 +160,7 @@ URL_PATTERN = re.compile( r"(https?://|www\.|)+(localhost|[a-zA-Z0-9.-]+(\.(?:" + "|".join(VALID_TLDS) - + "))+|[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})(:[0-9]+)?([/?][^\s]*)?", + + r"))+|[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})(:[0-9]+)?([/?][^\s]*)?", re.IGNORECASE, ) From ee2b44c14575f5a5467ec09856a131eaaee826e8 Mon Sep 17 00:00:00 2001 From: remsky Date: Fri, 22 May 2026 00:47:46 -0600 Subject: [PATCH 26/45] chore(changelog): update unreleased changes --- CHANGELOG.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a6343f7..7229961c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,20 @@ Notable changes to this project will be documented in this file. Per-PR attribution and contributor credits are published automatically on the corresponding GitHub release page; this file is the curated, human-readable summary. ## [v0.3.1] - Unreleased +### Added +- Integration test suite (`api/tests/integration/`, opt-in `integration` marker) and a `tts-api-test-client` image that round-trips speech through faster-whisper against a live server. Run via `docker/docker-compose.test.yml`. +- Web UI footer badge showing the server version from `/config`. + +### Changed +- `api_version` now read from the `VERSION` file instead of hardcoded. +- Removed the legacy `docker/{cpu,gpu}/Dockerfile`; the `.optimized` variants are the only build files now. + ### Fixed -- `/v1/audio/voices` now returns `[{"id": ..., "name": ...}, ...]` by default so OpenAI-compatible clients (notably Open WebUI, which reads `voice['id']` and silently falls back to a hardcoded 6-voice list otherwise) can render the full voice catalog (#462). Pass `?legacy=true` to get the pre-0.3.1 plain-string shape. -- cpu/gpu composes set `DOWNLOAD_MODEL=true`, which runs an idempotent fetch from the static release artifact on startup. `download_model.py` also runs on stock Python for the host fallback. +- `/v1/audio/voices` returns `[{"id", "name"}, ...]` objects by default so OpenAI-compatible clients like Open WebUI see the full voice catalog (#462). Pass `?legacy=true` for the old string shape. +- cpu/gpu composes set `DOWNLOAD_MODEL=true` for an idempotent model fetch on startup. +- Silence trimming no longer treats full-scale-negative samples as silent (`int16` `abs()` overflow). +- Fixed invalid escape sequences in the text-normalizer URL regex. +- CI test job uses the CPU PyTorch build and excludes integration tests by default. ## [v0.3.0] - 2026-05-15 ### Added From 65ef3b8106e0b443d82527fcdb9dff34cc23d688 Mon Sep 17 00:00:00 2001 From: remsky Date: Sat, 23 May 2026 13:53:42 -0600 Subject: [PATCH 27/45] feat(gpu): adds clear cu128 opt-in via compose, setup for Blackwell / RTX 50-series release image --- .github/workflows/release.yml | 78 ++++++++++++++++++++++++-------- .github/workflows/test_build.yml | 2 + CHANGELOG.md | 1 + README.md | 3 ++ docker-bake.hcl | 30 ++++++++++-- docker/gpu/Dockerfile.optimized | 7 ++- docker/gpu/docker-compose.yml | 5 ++ pyproject.toml | 15 ++++++ 8 files changed, 119 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dacc6272..99643d23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,6 +85,9 @@ jobs: - build_target: "gpu" platform: "linux/amd64" runs_on: "ubuntu-24.04" + - build_target: "gpu-cu128" + platform: "linux/amd64" + runs_on: "ubuntu-24.04" - build_target: "cpu" platform: "linux/arm64" runs_on: "ubuntu-24.04-arm" @@ -151,7 +154,7 @@ jobs: REPO: ${{ vars.REPO || 'kokoro-fastapi' }} strategy: matrix: - build_target: ["cpu", "gpu", "rocm"] + build_target: ["cpu", "gpu", "gpu-cu128", "rocm"] steps: - name: Log in to GitHub Container Registry uses: docker/login-action@v3 @@ -168,25 +171,64 @@ jobs: OWNER="${{ env.OWNER }}" REPO="${{ env.REPO }}" - # ROCm is x86-only, so the manifest references just the amd64 image. - if [[ "$TARGET" == "rocm" ]]; then - SOURCES=("${REGISTRY}/${OWNER}/${REPO}-${TARGET}:${VERSION_TAG}-amd64") - else - SOURCES=( - "${REGISTRY}/${OWNER}/${REPO}-${TARGET}:${VERSION_TAG}-amd64" - "${REGISTRY}/${OWNER}/${REPO}-${TARGET}:${VERSION_TAG}-arm64" - ) - fi - - docker buildx imagetools create -t \ - ${REGISTRY}/${OWNER}/${REPO}-${TARGET}:${VERSION_TAG} \ - "${SOURCES[@]}" - - # Publish :latest only for clean release tags (no branch suffix). - if [[ "$VERSION_TAG" != *"-"* ]]; then + # Resolve the package name, manifest tag(s), and source images per target. + # + # Tag layout per target: + # - cpu: :VERSION + :latest (multi-arch) + # - gpu: :VERSION + :VERSION-cu126 + :latest + :latest-cu126 + # (the -cu126 aliases make the wheel variant explicit so + # Maxwell/Pascal users can pin it across future default flips) + # - gpu-cu128: :VERSION-cu128 + :latest-cu128 (amd64 only) + # - rocm: :VERSION + :latest (amd64 only) + # + # MANIFEST_TAGS / LATEST_TAGS are space-separated lists; all entries point at + # the same SOURCES so the underlying blobs are deduplicated by digest. + case "$TARGET" in + gpu-cu128) + PACKAGE="${REPO}-gpu" + MANIFEST_TAGS="${VERSION_TAG}-cu128" + LATEST_TAGS="latest-cu128" + SOURCES=("${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-cu128-amd64") + ;; + gpu) + PACKAGE="${REPO}-gpu" + MANIFEST_TAGS="${VERSION_TAG} ${VERSION_TAG}-cu126" + LATEST_TAGS="latest latest-cu126" + SOURCES=( + "${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-amd64" + "${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-arm64" + ) + ;; + rocm) + PACKAGE="${REPO}-rocm" + MANIFEST_TAGS="${VERSION_TAG}" + LATEST_TAGS="latest" + SOURCES=("${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-amd64") + ;; + *) + PACKAGE="${REPO}-${TARGET}" + MANIFEST_TAGS="${VERSION_TAG}" + LATEST_TAGS="latest" + SOURCES=( + "${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-amd64" + "${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-arm64" + ) + ;; + esac + + for TAG in $MANIFEST_TAGS; do docker buildx imagetools create -t \ - ${REGISTRY}/${OWNER}/${REPO}-${TARGET}:latest \ + ${REGISTRY}/${OWNER}/${PACKAGE}:${TAG} \ "${SOURCES[@]}" + done + + # Publish the rolling tag(s) only for clean release tags (no branch suffix). + if [[ "$VERSION_TAG" != *"-"* ]]; then + for TAG in $LATEST_TAGS; do + docker buildx imagetools create -t \ + ${REGISTRY}/${OWNER}/${PACKAGE}:${TAG} \ + "${SOURCES[@]}" + done fi create-release: diff --git a/.github/workflows/test_build.yml b/.github/workflows/test_build.yml index 998432bf..4b3273f7 100644 --- a/.github/workflows/test_build.yml +++ b/.github/workflows/test_build.yml @@ -15,6 +15,7 @@ on: - all - cpu - gpu + - gpu-cu128 - rocm platform: description: 'Platform (ignored for rocm)' @@ -48,6 +49,7 @@ jobs: {"build_target":"cpu","platform":"arm64","runs_on":"ubuntu-24.04-arm"}, {"build_target":"gpu","platform":"amd64","runs_on":"ubuntu-24.04"}, {"build_target":"gpu","platform":"arm64","runs_on":"ubuntu-24.04-arm"}, + {"build_target":"gpu-cu128","platform":"amd64","runs_on":"ubuntu-24.04"}, {"build_target":"rocm","platform":"amd64","runs_on":"ubuntu-24.04"} ]' diff --git a/CHANGELOG.md b/CHANGELOG.md index 7229961c..7d4e1d00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Per-PR attribution and contributor credits are published automatically on the co ## [v0.3.1] - Unreleased ### Added +- GPU image variants for Blackwell / RTX 50-series (`:latest-cu128`, `:vX.Y.Z-cu128`, amd64 only) with PyTorch cu128 wheels (#443). Default `:latest` and new `:latest-cu126` alias stay on cu126 for Maxwell/Pascal compatibility. - Integration test suite (`api/tests/integration/`, opt-in `integration` marker) and a `tts-api-test-client` image that round-trips speech through faster-whisper against a live server. Run via `docker/docker-compose.test.yml`. - Web UI footer badge showing the server version from `/config`. diff --git a/README.md b/README.md index ff15595a..4859e606 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,12 @@ Refer to the core/config.py file for a full list of variables which can be manag docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest # CPU, or: docker run --gpus all -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-gpu:latest # NVIDIA GPU, or: +docker run --gpus all -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-gpu:latest-cu128 # NVIDIA Blackwell / RTX 50-series, or: docker run --device=/dev/kfd --device=/dev/dri -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-rocm:latest # AMD GPU (ROCm, experimental, amd64 only) ``` +> `:latest` defaults to cu126 (multi-arch, also published as `:latest-cu126`). `:latest-cu128` is the Blackwell / RTX 50-series variant (PyTorch cu128, amd64 only). Same suffixes on versioned tags. +
diff --git a/docker-bake.hcl b/docker-bake.hcl index 75ed2e01..8ab98adf 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -100,6 +100,20 @@ target "gpu-arm64" { ] } +# Blackwell / RTX 50-series variant: cu128 torch wheels (sm_120 kernels). +# x86_64 only; published as a -cu128 suffixed tag on the existing -gpu package. +target "gpu-cu128-amd64" { + inherits = ["_gpu_base"] + platforms = ["linux/amd64"] + args = { + CUDA_VERSION = "12.9.1" + GPU_EXTRA = "gpu-cu128" + } + tags = [ + "${REGISTRY}/${OWNER}/${REPO}-gpu:${VERSION}-cu128-amd64" + ] +} + # AMD ROCm only supports x86 target "rocm-amd64" { inherits = ["_rocm_base"] @@ -122,6 +136,16 @@ target "gpu-dev" { tags = ["${REGISTRY}/${OWNER}/${REPO}-gpu:dev"] } +target "gpu-cu128-dev" { + inherits = ["_gpu_base"] + # No multi-platform for dev builds + args = { + CUDA_VERSION = "12.9.1" + GPU_EXTRA = "gpu-cu128" + } + tags = ["${REGISTRY}/${OWNER}/${REPO}-gpu:dev-cu128"] +} + group "dev" { targets = ["cpu-dev", "gpu-dev"] } @@ -132,7 +156,7 @@ group "cpu-all" { } group "gpu-all" { - targets = ["gpu-amd64", "gpu-arm64"] + targets = ["gpu-amd64", "gpu-arm64", "gpu-cu128-amd64"] } group "rocm-all" { @@ -140,9 +164,9 @@ group "rocm-all" { } group "all" { - targets = ["cpu", "gpu-amd64", "gpu-arm64", "rocm-amd64"] + targets = ["cpu", "gpu-amd64", "gpu-arm64", "gpu-cu128-amd64", "rocm-amd64"] } group "individual-platforms" { - targets = ["cpu-amd64", "cpu-arm64", "gpu-amd64", "gpu-arm64", "rocm-amd64"] + targets = ["cpu-amd64", "cpu-arm64", "gpu-amd64", "gpu-arm64", "gpu-cu128-amd64", "rocm-amd64"] } diff --git a/docker/gpu/Dockerfile.optimized b/docker/gpu/Dockerfile.optimized index 7c172e32..2a805c35 100644 --- a/docker/gpu/Dockerfile.optimized +++ b/docker/gpu/Dockerfile.optimized @@ -24,8 +24,13 @@ COPY pyproject.toml ./pyproject.toml ENV UV_HTTP_TIMEOUT=120 UV_HTTP_RETRIES=3 \ UV_PYTHON_INSTALL_DIR=/opt/uv-python +# GPU_EXTRA selects the torch wheel set: "gpu" (cu126, keeps Maxwell/Pascal +# working) or "gpu-cu128" (cu128, adds Blackwell / RTX 50-series). Overridden +# per target in docker-bake.hcl. +ARG GPU_EXTRA=gpu + RUN uv venv --python 3.10 && \ - uv sync --extra gpu --no-cache --no-install-project + uv sync --extra ${GPU_EXTRA} --no-cache --no-install-project # Stage 2: Runtime - Use smaller runtime image FROM --platform=$BUILDPLATFORM nvcr.io/nvidia/cuda:${CUDA_VERSION}-cudnn-runtime-ubuntu24.04 diff --git a/docker/gpu/docker-compose.yml b/docker/gpu/docker-compose.yml index 335a6e85..636dd6bf 100644 --- a/docker/gpu/docker-compose.yml +++ b/docker/gpu/docker-compose.yml @@ -5,6 +5,11 @@ services: build: context: ../.. dockerfile: docker/gpu/Dockerfile.optimized + # Default builds cu126 wheels, multi-arch (Maxwell through Ada: GTX 9xx, 10xx, 20xx, 30xx, 40xx). + # Temp fix for NVIDIA Blackwell / RTX 50-series, uncomment: + # args: + # GPU_EXTRA: gpu-cu128 + # CUDA_VERSION: 12.9.1 volumes: - ../../api:/app/api user: "1001:1001" # Ensure container runs as UID 1001 (appuser) diff --git a/pyproject.toml b/pyproject.toml index 581fdffd..6b49b31e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,13 @@ gpu = [ "torch==2.8.0+cu126 ; platform_machine == 'x86_64'", "torch==2.8.0+cu129 ; platform_machine == 'aarch64'", ] +# Blackwell / RTX 50-series variant. cu128 wheels carry sm_120 kernels but +# drop Maxwell/Pascal, so this is a separate extra rather than the default. +# See #443. aarch64 already ships cu129 (Blackwell-capable) under both extras. +gpu-cu128 = [ + "torch==2.8.0+cu128 ; platform_machine == 'x86_64'", + "torch==2.8.0+cu129 ; platform_machine == 'aarch64'", +] cpu = ["torch==2.8.0"] rocm = [ "torch==2.8.0+rocm6.4", @@ -70,6 +77,7 @@ conflicts = [ [ { extra = "cpu" }, { extra = "gpu" }, + { extra = "gpu-cu128" }, { extra = "rocm" }, ], ] @@ -82,6 +90,8 @@ torch = [ { index = "pytorch-cpu", extra = "cpu" }, { index = "pytorch-cu126", extra = "gpu", marker = "platform_machine == 'x86_64'" }, { index = "pytorch-cu129", extra = "gpu", marker = "platform_machine == 'aarch64'" }, + { index = "pytorch-cu128", extra = "gpu-cu128", marker = "platform_machine == 'x86_64'" }, + { index = "pytorch-cu129", extra = "gpu-cu128", marker = "platform_machine == 'aarch64'" }, { index = "pytorch-rocm", extra = "rocm" }, ] pytorch-triton-rocm = [ @@ -98,6 +108,11 @@ name = "pytorch-cu126" url = "https://download.pytorch.org/whl/cu126" explicit = true +[[tool.uv.index]] +name = "pytorch-cu128" +url = "https://download.pytorch.org/whl/cu128" +explicit = true + [[tool.uv.index]] name = "pytorch-cu129" url = "https://download.pytorch.org/whl/cu129" From 0d6b4d89193bd18fc2bba47366a77db011143ab8 Mon Sep 17 00:00:00 2001 From: remsky Date: Sat, 23 May 2026 14:49:40 -0600 Subject: [PATCH 28/45] =?UTF-8?q?=20fix(docker):=20aligns=20cu128=20base?= =?UTF-8?q?=20image=20with=20wheel=20(12.9.1=20=E2=86=92=2012.8.1),=20mini?= =?UTF-8?q?mum=20toolkit=20with=20Blackwell=20(sm=5F120)=20support=20(CUDA?= =?UTF-8?q?=2012.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #306 #320 #389 #443 #365 --- docker-bake.hcl | 6 ++++-- docker/gpu/docker-compose.yml | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docker-bake.hcl b/docker-bake.hcl index 8ab98adf..73db04c0 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -106,7 +106,9 @@ target "gpu-cu128-amd64" { inherits = ["_gpu_base"] platforms = ["linux/amd64"] args = { - CUDA_VERSION = "12.9.1" + # 12.8.x is the first CUDA toolkit with Blackwell (sm_120) support and is + # what the cu128 torch wheels are built against. Keep base + wheel aligned. + CUDA_VERSION = "12.8.1" GPU_EXTRA = "gpu-cu128" } tags = [ @@ -140,7 +142,7 @@ target "gpu-cu128-dev" { inherits = ["_gpu_base"] # No multi-platform for dev builds args = { - CUDA_VERSION = "12.9.1" + CUDA_VERSION = "12.8.1" GPU_EXTRA = "gpu-cu128" } tags = ["${REGISTRY}/${OWNER}/${REPO}-gpu:dev-cu128"] diff --git a/docker/gpu/docker-compose.yml b/docker/gpu/docker-compose.yml index 636dd6bf..94413a28 100644 --- a/docker/gpu/docker-compose.yml +++ b/docker/gpu/docker-compose.yml @@ -9,7 +9,7 @@ services: # Temp fix for NVIDIA Blackwell / RTX 50-series, uncomment: # args: # GPU_EXTRA: gpu-cu128 - # CUDA_VERSION: 12.9.1 + # CUDA_VERSION: 12.8.1 volumes: - ../../api:/app/api user: "1001:1001" # Ensure container runs as UID 1001 (appuser) From f9bde23f2cb5e70d1c3cc8a99212596818e3b41e Mon Sep 17 00:00:00 2001 From: remsky Date: Sat, 23 May 2026 16:27:38 -0600 Subject: [PATCH 29/45] chore(bake): explicit wheel variant in per-arch gpu tags (cu126/cu128/cu129) docs(readme): wiki links --- .github/workflows/release.yml | 6 ++++-- CHANGELOG.md | 10 +++++----- README.md | 2 ++ VERSION | 2 +- docker-bake.hcl | 7 +++++-- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 99643d23..a08bf584 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -194,9 +194,11 @@ jobs: PACKAGE="${REPO}-gpu" MANIFEST_TAGS="${VERSION_TAG} ${VERSION_TAG}-cu126" LATEST_TAGS="latest latest-cu126" + # Per-arch sources carry their wheel variant (cu126 amd64, cu129 arm64); + # both feed the same multi-arch manifest tags above. SOURCES=( - "${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-amd64" - "${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-arm64" + "${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-cu126-amd64" + "${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-cu129-arm64" ) ;; rocm) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d4e1d00..2648299c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,18 +4,18 @@ Notable changes to this project will be documented in this file. Per-PR attribution and contributor credits are published automatically on the corresponding GitHub release page; this file is the curated, human-readable summary. -## [v0.3.1] - Unreleased +## [v0.4.0] - Unreleased ### Added - GPU image variants for Blackwell / RTX 50-series (`:latest-cu128`, `:vX.Y.Z-cu128`, amd64 only) with PyTorch cu128 wheels (#443). Default `:latest` and new `:latest-cu126` alias stay on cu126 for Maxwell/Pascal compatibility. - Integration test suite (`api/tests/integration/`, opt-in `integration` marker) and a `tts-api-test-client` image that round-trips speech through faster-whisper against a live server. Run via `docker/docker-compose.test.yml`. - Web UI footer badge showing the server version from `/config`. ### Changed +- `/v1/audio/voices` default response shape changed to `[{"id", "name"}, ...]` so OpenAI-compatible clients like Open WebUI see the full voice catalog (#462). Pass `?legacy=true` to restore the old `string[]` shape. - `api_version` now read from the `VERSION` file instead of hardcoded. - Removed the legacy `docker/{cpu,gpu}/Dockerfile`; the `.optimized` variants are the only build files now. ### Fixed -- `/v1/audio/voices` returns `[{"id", "name"}, ...]` objects by default so OpenAI-compatible clients like Open WebUI see the full voice catalog (#462). Pass `?legacy=true` for the old string shape. - cpu/gpu composes set `DOWNLOAD_MODEL=true` for an idempotent model fetch on startup. - Silence trimming no longer treats full-scale-negative samples as silent (`int16` `abs()` overflow). - Fixed invalid escape sequences in the text-normalizer URL regex. @@ -23,7 +23,7 @@ Per-PR attribution and contributor credits are published automatically on the co ## [v0.3.0] - 2026-05-15 ### Added -- AMD GPU support via ROCm (`docker/rocm/` build, `rocm` extra in `pyproject.toml`). +- AMD GPU support via ROCm (`docker/rocm/` build, `rocm` extra in `pyproject.toml`). Also explored/proposed via @asheghi in #393. - `gpt-4o-mini-tts` model alias for OpenAI-compatible clients. - Reverse-proxy support for the Web UI (new `/config` endpoint exposing `UVICORN_ROOT_PATH`). - Configurable logging level via the `API_LOG_LEVEL` environment variable. @@ -33,7 +33,7 @@ Per-PR attribution and contributor credits are published automatically on the co ### Changed - PyTorch bumped to 2.8.0 (x86_64: cu126, aarch64: cu129). x86_64 settled on cu126 to keep Maxwell/Pascal cards working, which drops native Blackwell (RTX 50-series) kernel support. Blackwell users need to override the torch index manually. See #443. -- `kokoro` bumped to 0.9.4 and `misaki` to 0.9.4. +- `kokoro` bumped to 0.9.4 and `misaki` to 0.9.4 (proposed by @jcheek in #371, superceded). - New optimized multi-stage Dockerfiles (`docker/{cpu,gpu}/Dockerfile.optimized`) become the default bake target. Reported image sizes: CPU 5.6 → 4.9 GB, GPU 14.8 → 9.9 GB. - Parallelized Docker bake targets per architecture for faster CI. - ROCBlas version pinned; ROCm docker-compose now builds locally. @@ -46,7 +46,7 @@ Per-PR attribution and contributor credits are published automatically on the co - Custom phoneme handling made significantly more robust. - Firefox Web UI playback falls back gracefully when `audio/mpeg` MSE is unsupported; waveform rendering bugfix bundled in the same web rewrite. - CPU Docker builds: Rust now installed for `appuser` with proper `PATH` and longer `uv` timeouts. -- `cmake` added to CI deps to unblock `pyopenjtalk` builds. +- `cmake` added to CI deps to unblock `pyopenjtalk` builds (proposed by @jcheek in #371; superceded). - `start-gpu.sh` uses `#!/usr/bin/env bash` for broader compatibility. - Apple Silicon: `test_initial_state()` no longer fails. diff --git a/README.md b/README.md index 4859e606..bf4f6100 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@

# _`FastKoko`_ +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/remsky/Kokoro-FastAPI) [![Ask CodeWiki](https://img.shields.io/badge/Ask%20CodeWiki-4285F4?logo=googlegemini&logoColor=white)](https://codewiki.google/github.com/remsky/kokoro-fastapi) + [![Tests](https://img.shields.io/badge/tests-81-darkgreen)]() [![Coverage](https://img.shields.io/badge/coverage-52%25-tan)]() [![Changelog](https://img.shields.io/badge/changelog-white)](./CHANGELOG.md) diff --git a/VERSION b/VERSION index 8403f23f..a57bda5d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.1-rc1 \ No newline at end of file +0.4.0-rc1 diff --git a/docker-bake.hcl b/docker-bake.hcl index 73db04c0..1697892a 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -84,8 +84,10 @@ target "gpu-amd64" { args = { CUDA_VERSION = "12.6.3" } + # Per-arch tag carries the wheel variant so it parallels gpu-cu128-amd64. + # The published manifest still resolves to :VERSION / :VERSION-cu126 via release.yml. tags = [ - "${REGISTRY}/${OWNER}/${REPO}-gpu:${VERSION}-amd64" + "${REGISTRY}/${OWNER}/${REPO}-gpu:${VERSION}-cu126-amd64" ] } @@ -95,8 +97,9 @@ target "gpu-arm64" { args = { CUDA_VERSION = "12.9.1" } + # aarch64 uses cu129 wheels (no cu126 aarch64 wheels exist on pytorch.org). tags = [ - "${REGISTRY}/${OWNER}/${REPO}-gpu:${VERSION}-arm64" + "${REGISTRY}/${OWNER}/${REPO}-gpu:${VERSION}-cu129-arm64" ] } From 7649d86b5f239db3ffe35aa7d4d111d9f21cebc7 Mon Sep 17 00:00:00 2001 From: remsky Date: Sun, 24 May 2026 15:28:26 -0600 Subject: [PATCH 30/45] fix(audio): drop WAV close()-time junk trailer that caused chunk-end click (#467) Fixes #463 --- CHANGELOG.md | 1 + README.md | 9 +++++---- api/src/services/streaming_audio_writer.py | 8 +++++++- web/src/services/AudioService.js | 2 +- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2648299c..984522de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Per-PR attribution and contributor credits are published automatically on the co - Removed the legacy `docker/{cpu,gpu}/Dockerfile`; the `.optimized` variants are the only build files now. ### Fixed +- WAV responses drop junk size-field trailer that decoded as a click at chunk end. (#463) - cpu/gpu composes set `DOWNLOAD_MODEL=true` for an idempotent model fetch on startup. - Silence trimming no longer treats full-scale-negative samples as silent (`int16` `abs()` overflow). - Fixed invalid escape sequences in the text-normalizer URL regex. diff --git a/README.md b/README.md index bf4f6100..5b5bfa84 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,10 @@ Kokoro TTS Banner

-# _`FastKoko`_ -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/remsky/Kokoro-FastAPI) [![Ask CodeWiki](https://img.shields.io/badge/Ask%20CodeWiki-4285F4?logo=googlegemini&logoColor=white)](https://codewiki.google/github.com/remsky/kokoro-fastapi) +# _`FastKoko`_ -[![Tests](https://img.shields.io/badge/tests-81-darkgreen)]() -[![Coverage](https://img.shields.io/badge/coverage-52%25-tan)]() [![Changelog](https://img.shields.io/badge/changelog-white)](./CHANGELOG.md) + [![Changelog](https://img.shields.io/badge/changelog-white)](./CHANGELOG.md) [![Tests](https://img.shields.io/badge/tests-81-darkgreen)]() +[![Coverage](https://img.shields.io/badge/coverage-52%25-tan)]() [![Downloads](https://img.shields.io/badge/downloads-1.4M%2B-2496ED?logo=docker&logoColor=white)](https://github.com/remsky?tab=packages&repo_name=Kokoro-FastAPI) [![Kokoro](https://img.shields.io/badge/kokoro-0.9.4-BB5420)](https://github.com/hexgrad/kokoro) [![Misaki](https://img.shields.io/badge/misaki-0.9.4-B8860B)](https://github.com/hexgrad/misaki) @@ -26,6 +25,8 @@ Dockerized FastAPI wrapper for [Kokoro-82M](https://huggingface.co/hexgrad/Kokor ### Integration Guides +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/remsky/Kokoro-FastAPI) [![Ask CodeWiki](https://img.shields.io/badge/Ask%20CodeWiki-4285F4?logo=googlegemini&logoColor=white)](https://codewiki.google/github.com/remsky/kokoro-fastapi) + [![Helm Chart](https://img.shields.io/badge/Helm%20Chart-black?style=flat&logo=helm&logoColor=white)](https://github.com/remsky/Kokoro-FastAPI/wiki/Setup-Kubernetes) [![DigitalOcean](https://img.shields.io/badge/DigitalOcean-black?style=flat&logo=digitalocean&logoColor=white)](https://github.com/remsky/Kokoro-FastAPI/wiki/Integrations-DigitalOcean) [![SillyTavern](https://img.shields.io/badge/SillyTavern-black?style=flat&color=red)](https://github.com/remsky/Kokoro-FastAPI/wiki/Integrations-SillyTavern) [![OpenWebUI](https://img.shields.io/badge/OpenWebUI-black?style=flat&color=white)](https://github.com/remsky/Kokoro-FastAPI/wiki/Integrations-OpenWebUi) ## Get Started diff --git a/api/src/services/streaming_audio_writer.py b/api/src/services/streaming_audio_writer.py index b1566f0b..137c35a0 100644 --- a/api/src/services/streaming_audio_writer.py +++ b/api/src/services/streaming_audio_writer.py @@ -80,7 +80,7 @@ def write_chunk( for packet in packets: self.container.mux(packet) - # Close the container FIRST — this writes the final OGG page + # Close the container FIRST. this writes the final OGG page # (or other format trailer) to the output buffer. For OGG/Opus, # the last page of audio data is only written during close(). self.container.close() @@ -89,6 +89,12 @@ def write_chunk( # Now read the buffer which includes all trailing data data = self.output_buffer.getvalue() self.output_buffer.close() + + if self.format == "wav": + # close()'s seek-and-patch lands ~78 bytes of size-field + # junk in the truncated buffer. Decoded as samples it's + # an audible click at chunk end. issue #463. + return b"" return data if audio_data is None or len(audio_data) == 0: diff --git a/web/src/services/AudioService.js b/web/src/services/AudioService.js index 0c1d7ace..63515410 100644 --- a/web/src/services/AudioService.js +++ b/web/src/services/AudioService.js @@ -156,7 +156,7 @@ export class AudioService { this.audio.src = this.objectUrl; this.audio.addEventListener('error', () => { - console.error('Audio error:', this.audio.error); + console.error('Audio error:', this.audio?.error); }); this.audio.addEventListener('ended', () => { From 75acf86fc74e7271d978f9bf9114e478cadd4f50 Mon Sep 17 00:00:00 2001 From: remsky Date: Sun, 24 May 2026 15:41:00 -0600 Subject: [PATCH 31/45] chore(docker): add oci metadata, copy VERSION for web ui, prebuilt image for tts-client tests (#468) --- .github/workflows/release.yml | 44 ++++++++++++++++++++++--- .gitignore | 1 + CHANGELOG.md | 2 ++ docker-bake.hcl | 57 ++++++++++++++++++++++++++++++++- docker/cpu/Dockerfile.optimized | 3 +- docker/docker-compose.test.yml | 2 +- docker/gpu/Dockerfile.optimized | 1 + docker/rocm/Dockerfile | 1 + 8 files changed, 104 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a08bf584..0b24ffe3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -139,6 +139,11 @@ jobs: TARGET="${BUILD_TARGET}-$(echo ${PLATFORM} | cut -d'/' -f2)" echo "Using bake target: $TARGET" + # Bake reads REVISION/CREATED as HCL variables; both populate + # org.opencontainers.image.* labels and annotations on the per-arch push. + export REVISION="${GITHUB_SHA}" + export CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + docker buildx bake $TARGET --push --progress=plain \ --set "*.cache-from=type=gha,scope=${TARGET}" \ --set "*.cache-to=type=gha,mode=max,scope=${TARGET}" @@ -183,12 +188,19 @@ jobs: # # MANIFEST_TAGS / LATEST_TAGS are space-separated lists; all entries point at # the same SOURCES so the underlying blobs are deduplicated by digest. + # + # TITLE / DESCRIPTION are attached as index-level OCI annotations. + # GHCR reads org.opencontainers.image.description from the index manifest + # for multi-arch packages, so the per-arch labels set by bake are not + # enough on their own. case "$TARGET" in gpu-cu128) PACKAGE="${REPO}-gpu" MANIFEST_TAGS="${VERSION_TAG}-cu128" LATEST_TAGS="latest-cu128" SOURCES=("${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-cu128-amd64") + TITLE="Kokoro-FastAPI (GPU, cu128 / Blackwell)" + DESCRIPTION="Kokoro TTS served via FastAPI. NVIDIA GPU build with CUDA 12.8 + cu128 torch wheels for RTX 50-series (Blackwell, sm_120). amd64 only." ;; gpu) PACKAGE="${REPO}-gpu" @@ -200,12 +212,16 @@ jobs: "${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-cu126-amd64" "${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-cu129-arm64" ) + TITLE="Kokoro-FastAPI (GPU)" + DESCRIPTION="Kokoro TTS served via FastAPI. NVIDIA GPU build, multi-arch (CUDA 12.6 + cu126 wheels on amd64, CUDA 12.9 + cu129 wheels on arm64). See the -cu128 tag for Blackwell." ;; rocm) PACKAGE="${REPO}-rocm" MANIFEST_TAGS="${VERSION_TAG}" LATEST_TAGS="latest" SOURCES=("${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-amd64") + TITLE="Kokoro-FastAPI (ROCm)" + DESCRIPTION="Kokoro TTS served via FastAPI. AMD ROCm build. amd64 only." ;; *) PACKAGE="${REPO}-${TARGET}" @@ -215,20 +231,40 @@ jobs: "${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-amd64" "${REGISTRY}/${OWNER}/${PACKAGE}:${VERSION_TAG}-arm64" ) + TITLE="Kokoro-FastAPI (CPU)" + DESCRIPTION="Kokoro TTS served via FastAPI. CPU build, multi-arch (amd64 + arm64)." ;; esac + # Common index annotations. `index:` is required so these land on the + # index manifest itself, not on per-platform descriptors inside it. + SOURCE_URL="https://github.com/${OWNER}/Kokoro-FastAPI" + CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + REVISION="${GITHUB_SHA}" + ANNOTATIONS=( + --annotation "index:org.opencontainers.image.title=${TITLE}" + --annotation "index:org.opencontainers.image.description=${DESCRIPTION}" + --annotation "index:org.opencontainers.image.source=${SOURCE_URL}" + --annotation "index:org.opencontainers.image.url=${SOURCE_URL}" + --annotation "index:org.opencontainers.image.licenses=Apache-2.0" + --annotation "index:org.opencontainers.image.version=${VERSION_TAG}" + --annotation "index:org.opencontainers.image.revision=${REVISION}" + --annotation "index:org.opencontainers.image.created=${CREATED}" + ) + for TAG in $MANIFEST_TAGS; do - docker buildx imagetools create -t \ - ${REGISTRY}/${OWNER}/${PACKAGE}:${TAG} \ + docker buildx imagetools create \ + "${ANNOTATIONS[@]}" \ + -t ${REGISTRY}/${OWNER}/${PACKAGE}:${TAG} \ "${SOURCES[@]}" done # Publish the rolling tag(s) only for clean release tags (no branch suffix). if [[ "$VERSION_TAG" != *"-"* ]]; then for TAG in $LATEST_TAGS; do - docker buildx imagetools create -t \ - ${REGISTRY}/${OWNER}/${PACKAGE}:${TAG} \ + docker buildx imagetools create \ + "${ANNOTATIONS[@]}" \ + -t ${REGISTRY}/${OWNER}/${PACKAGE}:${TAG} \ "${SOURCES[@]}" done fi diff --git a/.gitignore b/.gitignore index 3f2e9c4c..40746abe 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,4 @@ pyproject.toml.bkp # Local scratch notes, scan outputs, anything not meant to ship .local/ +examples/assorted_checks/test_silence/out/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 984522de..a3b8426e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,12 @@ Per-PR attribution and contributor credits are published automatically on the co - `/v1/audio/voices` default response shape changed to `[{"id", "name"}, ...]` so OpenAI-compatible clients like Open WebUI see the full voice catalog (#462). Pass `?legacy=true` to restore the old `string[]` shape. - `api_version` now read from the `VERSION` file instead of hardcoded. - Removed the legacy `docker/{cpu,gpu}/Dockerfile`; the `.optimized` variants are the only build files now. +- Docker images carry OCI metadata so GHCR pages render properly. Integration compose defaults to the published test-client image. ### Fixed - WAV responses drop junk size-field trailer that decoded as a click at chunk end. (#463) - cpu/gpu composes set `DOWNLOAD_MODEL=true` for an idempotent model fetch on startup. +- `VERSION` shipped into images so `/config` reports the real server version. - Silence trimming no longer treats full-scale-negative samples as silent (`int16` `abs()` overflow). - Fixed invalid escape sequences in the text-normalizer URL regex. - CI test job uses the CPU PyTorch build and excludes integration tests by default. diff --git a/docker-bake.hcl b/docker-bake.hcl index 1697892a..70e49ba8 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -19,25 +19,72 @@ variable "DOWNLOAD_MODEL" { default = "true" } -# Common settings shared between targets +# Source-control revision + build timestamp, populated from CI env. +# Left blank for local builds so the resulting labels/annotations stay empty +# rather than carrying stale values. +variable "REVISION" { + default = "" +} + +variable "CREATED" { + default = "" +} + +# OCI metadata applied to every image. `labels` lands in the image config +# (visible via `docker inspect`); `annotations` lands on the pushed manifest +# (which is what GHCR reads for per-arch package pages). Index-level +# annotations for the multi-arch tag are added in release.yml at +# `imagetools create` time, since bake here only produces per-arch manifests. target "_common" { context = "." args = { DEBIAN_FRONTEND = "noninteractive" DOWNLOAD_MODEL = "${DOWNLOAD_MODEL}" } + labels = { + "org.opencontainers.image.source" = "https://github.com/${OWNER}/Kokoro-FastAPI" + "org.opencontainers.image.url" = "https://github.com/${OWNER}/Kokoro-FastAPI" + "org.opencontainers.image.licenses" = "Apache-2.0" + "org.opencontainers.image.revision" = "${REVISION}" + "org.opencontainers.image.version" = "${VERSION}" + "org.opencontainers.image.created" = "${CREATED}" + } + annotations = [ + "org.opencontainers.image.source=https://github.com/${OWNER}/Kokoro-FastAPI", + "org.opencontainers.image.url=https://github.com/${OWNER}/Kokoro-FastAPI", + "org.opencontainers.image.licenses=Apache-2.0", + "org.opencontainers.image.revision=${REVISION}", + "org.opencontainers.image.version=${VERSION}", + "org.opencontainers.image.created=${CREATED}", + ] } # Base settings for CPU builds target "_cpu_base" { inherits = ["_common"] dockerfile = "docker/cpu/Dockerfile.optimized" + labels = { + "org.opencontainers.image.title" = "Kokoro-FastAPI (CPU)" + "org.opencontainers.image.description" = "Kokoro TTS served via FastAPI. CPU build." + } + annotations = [ + "org.opencontainers.image.title=Kokoro-FastAPI (CPU)", + "org.opencontainers.image.description=Kokoro TTS served via FastAPI. CPU build.", + ] } # Base settings for GPU builds target "_gpu_base" { inherits = ["_common"] dockerfile = "docker/gpu/Dockerfile.optimized" + labels = { + "org.opencontainers.image.title" = "Kokoro-FastAPI (GPU)" + "org.opencontainers.image.description" = "Kokoro TTS served via FastAPI. NVIDIA GPU build (CUDA 12.6 amd64 / CUDA 12.9 arm64; cu128 tag for Blackwell)." + } + annotations = [ + "org.opencontainers.image.title=Kokoro-FastAPI (GPU)", + "org.opencontainers.image.description=Kokoro TTS served via FastAPI. NVIDIA GPU build (CUDA 12.6 amd64 / CUDA 12.9 arm64; cu128 tag for Blackwell).", + ] } # CPU target with multi-platform support @@ -58,6 +105,14 @@ group "gpu" { target "_rocm_base" { inherits = ["_common"] dockerfile = "docker/rocm/Dockerfile" + labels = { + "org.opencontainers.image.title" = "Kokoro-FastAPI (ROCm)" + "org.opencontainers.image.description" = "Kokoro TTS served via FastAPI. AMD ROCm build (amd64 only)." + } + annotations = [ + "org.opencontainers.image.title=Kokoro-FastAPI (ROCm)", + "org.opencontainers.image.description=Kokoro TTS served via FastAPI. AMD ROCm build (amd64 only).", + ] } diff --git a/docker/cpu/Dockerfile.optimized b/docker/cpu/Dockerfile.optimized index 2d401434..6791eefe 100644 --- a/docker/cpu/Dockerfile.optimized +++ b/docker/cpu/Dockerfile.optimized @@ -1,5 +1,5 @@ # Stage 1: Builder -FROM python:3.10 as builder +FROM python:3.10 AS builder # Install build dependencies RUN apt-get update -y && \ @@ -64,6 +64,7 @@ RUN if [ "$INCLUDE_JAPANESE" = "true" ]; then \ # Copy project files COPY --chown=appuser:appuser api ./api COPY --chown=appuser:appuser web ./web +COPY --chown=appuser:appuser VERSION ./VERSION COPY --chown=appuser:appuser docker/scripts/entrypoint.sh ./entrypoint.sh RUN chmod +x ./entrypoint.sh diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml index dbbfee11..7cc99d1a 100644 --- a/docker/docker-compose.test.yml +++ b/docker/docker-compose.test.yml @@ -39,7 +39,7 @@ services: start_period: 30s test-client: - image: ${TEST_CLIENT_IMAGE:-tts-api-test-client:local} + image: ${TEST_CLIENT_IMAGE:-ghcr.io/remsky/tts-api-test-client:latest} build: context: ./test-client depends_on: diff --git a/docker/gpu/Dockerfile.optimized b/docker/gpu/Dockerfile.optimized index 2a805c35..cd45c7ed 100644 --- a/docker/gpu/Dockerfile.optimized +++ b/docker/gpu/Dockerfile.optimized @@ -77,6 +77,7 @@ RUN if [ "$INCLUDE_JAPANESE" = "true" ]; then \ COPY --chown=appuser:appuser pyproject.toml ./pyproject.toml COPY --chown=appuser:appuser api ./api COPY --chown=appuser:appuser web ./web +COPY --chown=appuser:appuser VERSION ./VERSION COPY --chown=appuser:appuser docker/scripts/entrypoint.sh ./entrypoint.sh RUN chmod +x ./entrypoint.sh diff --git a/docker/rocm/Dockerfile b/docker/rocm/Dockerfile index 43c6f005..065baaac 100644 --- a/docker/rocm/Dockerfile +++ b/docker/rocm/Dockerfile @@ -73,6 +73,7 @@ RUN cd /tmp && wget https://archive.archlinux.org/packages/r/rocblas/rocblas-${R # Copy project files including models COPY --chown=appuser:appuser api ./api COPY --chown=appuser:appuser web ./web +COPY --chown=appuser:appuser VERSION ./VERSION COPY --chown=appuser:appuser docker/scripts/ ./ RUN chmod +x ./entrypoint.sh From 8ce082126d5e5959c0a51834b20684d305b8d658 Mon Sep 17 00:00:00 2001 From: remsky Date: Sun, 24 May 2026 22:40:59 -0600 Subject: [PATCH 32/45] fix(rocm): persist MIOpen cache and add FIND_MODE=2 default +warmup script (#469) --- CHANGELOG.md | 8 +- README.md | 134 +++++++++++++++++---------------- docker/rocm/Dockerfile | 16 +++- docker/rocm/docker-compose.yml | 33 ++++---- docker/rocm/warmup_miopen.py | 85 +++++++++++++++++++++ 5 files changed, 194 insertions(+), 82 deletions(-) create mode 100644 docker/rocm/warmup_miopen.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a3b8426e..4f9530c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,20 @@ Per-PR attribution and contributor credits are published automatically on the co - Integration test suite (`api/tests/integration/`, opt-in `integration` marker) and a `tts-api-test-client` image that round-trips speech through faster-whisper against a live server. Run via `docker/docker-compose.test.yml`. - Web UI footer badge showing the server version from `/config`. +### Breaking changes +- `/v1/audio/voices` items in the `voices` array changed from plain strings to `{"id", "name"}` objects (#462) to match OpenWebUI/similar clients, and allow metadata in the response. Clients reading entries as strings will break; pass `?legacy=true` to restore the old item shape. + - Old: `{"voices": ["af_heart", ...]}` + - New: `{"voices": [{"id": "af_heart", "name": "af_heart"}, ...]}` + ### Changed -- `/v1/audio/voices` default response shape changed to `[{"id", "name"}, ...]` so OpenAI-compatible clients like Open WebUI see the full voice catalog (#462). Pass `?legacy=true` to restore the old `string[]` shape. - `api_version` now read from the `VERSION` file instead of hardcoded. - Removed the legacy `docker/{cpu,gpu}/Dockerfile`; the `.optimized` variants are the only build files now. - Docker images carry OCI metadata so GHCR pages render properly. Integration compose defaults to the published test-client image. +- ROCm image defaults to `MIOPEN_FIND_MODE=2` so the on-disk kernel cache is reused instead of re-searched per process, and ships an opt-in warmup script at `docker/rocm/warmup_miopen.py` to pre-populate it. Recipe and benchmarks from @realugbun in #454. ### Fixed - WAV responses drop junk size-field trailer that decoded as a click at chunk end. (#463) +- ROCm MIOpen cache set to persist across compose restarts; switched bind mounts to named volumes at the path MIOpen writes to (prior mounts targeted an inaccessible location). - cpu/gpu composes set `DOWNLOAD_MODEL=true` for an idempotent model fetch on startup. - `VERSION` shipped into images so `/config` reports the real server version. - Silence trimming no longer treats full-scale-negative samples as silent (`int16` `abs()` overflow). diff --git a/README.md b/README.md index 5b5bfa84..9ccc503e 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,15 @@

# _`FastKoko`_ - - [![Changelog](https://img.shields.io/badge/changelog-white)](./CHANGELOG.md) [![Tests](https://img.shields.io/badge/tests-81-darkgreen)]() -[![Coverage](https://img.shields.io/badge/coverage-52%25-tan)]() [![Downloads](https://img.shields.io/badge/downloads-1.4M%2B-2496ED?logo=docker&logoColor=white)](https://github.com/remsky?tab=packages&repo_name=Kokoro-FastAPI) +[![Changelog](https://img.shields.io/badge/changelog-white)](./CHANGELOG.md) [![Tests](https://img.shields.io/badge/tests-81-darkgreen)]() +[![Coverage](https://img.shields.io/badge/coverage-52%25-tan)]() [![Kokoro](https://img.shields.io/badge/kokoro-0.9.4-BB5420)](https://github.com/hexgrad/kokoro) [![Misaki](https://img.shields.io/badge/misaki-0.9.4-B8860B)](https://github.com/hexgrad/misaki) +[![Tested at Model Commit](https://img.shields.io/badge/model-1.0::9901c2b-blue)](https://huggingface.co/hexgrad/Kokoro-82M/commit/9901c2b79161b6e898b7ea857ae5298f47b8b0d6) + +[![Try on Spaces](https://img.shields.io/badge/%F0%9F%A4%97%20Try%20on-Spaces-blue)](https://huggingface.co/spaces/Remsky/FastKoko) [![Downloads](https://img.shields.io/badge/downloads-1.4M%2B-2496ED?logo=docker&logoColor=white)](https://github.com/remsky?tab=packages&repo_name=Kokoro-FastAPI) -[![Tested at Model Commit](https://img.shields.io/badge/last--tested--model--commit-1.0::9901c2b-blue)](https://huggingface.co/hexgrad/Kokoro-82M/commit/9901c2b79161b6e898b7ea857ae5298f47b8b0d6) [![Try on Spaces](https://img.shields.io/badge/%F0%9F%A4%97%20Try%20on-Spaces-blue)](https://huggingface.co/spaces/Remsky/FastKoko) Dockerized FastAPI wrapper for [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) text-to-speech model @@ -34,23 +35,27 @@ Dockerized FastAPI wrapper for [Kokoro-82M](https://huggingface.co/hexgrad/Kokor
Quickest Start (docker run) +Pre-built multi-arch images with models baked in. + +`:latest` is available, but please pin to a release tag for stable usage. -Pre built images are available to run, with arm/multi-arch support, and baked in models -Refer to the core/config.py file for a full list of variables which can be managed via the environment +| Your hardware | Image | +|---|---| +| No GPU (any laptop, VPS, CPU-only server) | `kokoro-fastapi-cpu:latest` | +| Apple Silicon (M1/M2/M3) | `kokoro-fastapi-cpu:latest` in Docker, or `./start-gpu_mac.sh` natively for MPS | +| NVIDIA GTX 9xx, 10xx, 20xx, 30xx, 40xx (x86_64) | `kokoro-fastapi-gpu:latest-cu126` or `kokoro-fastapi-gpu:latest` | +| NVIDIA RTX 50-series / Blackwell (x86_64) | `kokoro-fastapi-gpu:latest-cu128` | +| NVIDIA on arm64 (Jetson, GH200) | `kokoro-fastapi-gpu:latest` (ships cu129, no cu126 arm64 wheels upstream) | +| AMD GPU | `kokoro-fastapi-rocm:latest` (experimental, x86_64 only) | ```bash -# the `latest` tag can be used, though it may have some unexpected bonus features which impact stability. -### Named versions should be pinned for your regular usage. -### Feedback/testing is always welcome - -docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest # CPU, or: -docker run --gpus all -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-gpu:latest # NVIDIA GPU, or: -docker run --gpus all -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-gpu:latest-cu128 # NVIDIA Blackwell / RTX 50-series, or: -docker run --device=/dev/kfd --device=/dev/dri -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-rocm:latest # AMD GPU (ROCm, experimental, amd64 only) +docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest # CPU +docker run --gpus all -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-gpu:latest # NVIDIA (x86_64 or arm64) +docker run --gpus all -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-gpu:latest-cu128 # NVIDIA Blackwell / RTX 50-series +docker run --device=/dev/kfd --device=/dev/dri -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-rocm:latest # AMD ``` -> `:latest` defaults to cu126 (multi-arch, also published as `:latest-cu126`). `:latest-cu128` is the Blackwell / RTX 50-series variant (PyTorch cu128, amd64 only). Same suffixes on versioned tags. - +Configuration via environment variables, see `core/config.py`. The `:latest` and `:latest-cu126` tags resolve to the same multi-arch image.
@@ -344,10 +349,28 @@ Key Streaming Metrics: ## Processing Details
-Performance Benchmarks +Performance & Benchmarks + +### Hardware variants + +```bash +# GPU: Requires NVIDIA driver with CUDA 12.6+ support (~35x-100x realtime speed) +cd docker/gpu +docker compose up --build + +# CPU: PyTorch CPU inference +cd docker/cpu +docker compose up --build + +# AMD GPU: ROCm 6.4 (experimental, amd64 only) +cd docker/rocm +docker compose up --build +``` + +### Throughput -Benchmarking was performed on generation via the local API using text lengths up to feature-length books (~1.5 hours output), measuring processing time and realtime factor. Tests were run on: -- Windows 11 Home w/ WSL2 +Benchmarking was performed on generation via the local API using text lengths up to feature-length books (~1.5 hours output), measuring processing time and realtime factor. Tests were run on: +- Windows 11 Home w/ WSL2 - NVIDIA 4060Ti 16gb GPU @ CUDA 12.1 - 11th Gen i7-11700 @ 2.5GHz - 64gb RAM @@ -362,10 +385,8 @@ Benchmarking was performed on generation via the local API using text lengths up Key Performance Metrics: - Realtime Speed: Ranges between 35x-100x (generation time to output audio length) - Average Processing Rate: 137.67 tokens/second (cl100k_base) -
-
-Transcription Roundtrip (WER/CER) +### Transcription roundtrip (WER/CER) End-to-end roundtrip: synthesize with Kokoro, transcribe the result back with [`faster-whisper`](https://github.com/SYSTRAN/faster-whisper), compare to the source text. Scripts and data live under `examples/assorted_checks/test_transcription/`. @@ -397,25 +418,6 @@ See `examples/assorted_checks/test_transcription/BASELINE.md` for the full regre To reproduce, see `examples/assorted_checks/test_transcription/README.md`.
-
-GPU Vs. CPU - -```bash -# GPU: Requires NVIDIA driver with CUDA 12.6+ support (~35x-100x realtime speed) -cd docker/gpu -docker compose up --build - -# CPU: PyTorch CPU inference -cd docker/cpu -docker compose up --build - -# AMD GPU: ROCm 6.4 (experimental, amd64 only) -cd docker/rocm -docker compose up --build - -``` -
-
Natural Boundary Detection @@ -616,30 +618,6 @@ for chunk in response.iter_content(chunk_size=1024): pass ``` -
- -
-Versioning & Development - -**Branching Strategy:** -* **`release` branch:** Contains the latest stable build, recommended for production use. Docker images tagged with specific versions (e.g., `v0.3.0`) are built from this branch. -* **`master` branch:** Used for active development. It may contain experimental features, ongoing changes, or fixes not yet in a stable release. Use this branch if you want the absolute latest code, but be aware it might be less stable. The `latest` Docker tag often points to builds from this branch. - -Note: This is a *development* focused project at its core. - -If you run into trouble, you may have to roll back a version on the release tags if something comes up, or build up from source and/or troubleshoot + submit a PR. - -Free and open source is a community effort, and there's only really so many hours in a day. If you'd like to support the work, feel free to open a PR, buy me a coffee, or report any bugs/features/etc you find during use. - - - Buy Me A Coffee - - -
@@ -687,7 +665,31 @@ Visit [NVIDIA Container Toolkit installation](https://docs.nvidia.com/datacenter
-## Model and License +## Project + +
+Versioning & Development + +**Branching Strategy:** +* **`release` branch:** Contains the latest stable build, recommended for production use. Docker images tagged with specific versions are built from this branch. +* **`master` branch:** Used for active development. It may contain experimental features, ongoing changes, or fixes not yet in a stable release. Use this branch if you want the absolute latest code, but be aware it might be less stable. The `latest` Docker tag often points to builds from this branch. + +Note: This is a *development* focused project at its core. + +If you run into trouble, you may have to roll back a version on the release tags if something comes up, or build up from source and/or troubleshoot + submit a PR. + +Free and open source is a community effort, and there's only really so many hours in a day. If you'd like to support the work, feel free to open a PR, buy me a coffee, or report any bugs/features/etc you find during use. + + + Buy Me A Coffee + + + +
Model diff --git a/docker/rocm/Dockerfile b/docker/rocm/Dockerfile index 065baaac..b77dd09c 100644 --- a/docker/rocm/Dockerfile +++ b/docker/rocm/Dockerfile @@ -75,17 +75,29 @@ COPY --chown=appuser:appuser api ./api COPY --chown=appuser:appuser web ./web COPY --chown=appuser:appuser VERSION ./VERSION COPY --chown=appuser:appuser docker/scripts/ ./ +COPY --chown=appuser:appuser docker/rocm/warmup_miopen.py /app/docker/rocm/warmup_miopen.py RUN chmod +x ./entrypoint.sh -# Set all environment variables in one go +# Pre-create MIOpen cache dirs so named-volume mounts inherit appuser +# ownership (Docker would otherwise create the mount target as root:root +# and MIOpen as uid 1001 could not write to it on first run). +RUN mkdir -p /home/appuser/.config/miopen /home/appuser/.cache/miopen + +# Set all environment variables in one go. +# MIOPEN_FIND_MODE=2 (FAST): reuse the on-disk find DB when present, fall back +# to immediate-mode heuristics otherwise. Cheap default that avoids the 5-60s +# per-shape kernel search on first hit. See docker/rocm/warmup_miopen.py to +# pre-populate the cache for best-case latency. Override at runtime to redo +# the search (e.g. MIOPEN_FIND_MODE=3 + MIOPEN_FIND_ENFORCE=3). ENV PYTHONUNBUFFERED=1 \ PYTHONPATH=/app:/app/api \ PATH="/app/.venv/bin:$PATH" \ UV_LINK_MODE=copy \ USE_GPU=true \ DOWNLOAD_MODEL=true \ - DEVICE="gpu" + DEVICE="gpu" \ + MIOPEN_FIND_MODE=2 # Run FastAPI server through entrypoint.sh CMD ["./entrypoint.sh"] diff --git a/docker/rocm/docker-compose.yml b/docker/rocm/docker-compose.yml index 3a59d5d0..73c94ff5 100644 --- a/docker/rocm/docker-compose.yml +++ b/docker/rocm/docker-compose.yml @@ -15,25 +15,32 @@ services: - 993 - 996 restart: 'always' + # Named volumes persist the MIOpen find DB and kernel cache at the paths + # MIOpen actually writes to ($HOME/.config/miopen and $HOME/.cache/miopen + # for appuser). Defined at the bottom of this file. Survives + # `docker compose down`; `docker compose down -v` clears them. Inspect + # contents with `docker volume inspect kokoro-fastapi-rocm_miopen_cache`. volumes: - - ./kokoro-tts/config:/root/.config/miopen - - ./kokoro-tts/cache:/root/.cache/miopen + - miopen_config:/home/appuser/.config/miopen + - miopen_cache:/home/appuser/.cache/miopen ports: - 8880:8880 environment: - USE_GPU=true - TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1 - # IMPORTANT: This is only required for RDNA 2 GPUs. You do not need the following steps if you use GPUS that are RDNA 1 (gfx1030) or older. - # ROCm's MIOpen libray will be slow if it has to figure out the optimal kernel shapes for each model - # See documentation on performancing tuning: https://github.com/ROCm/MIOpen/blob/develop/docs/conceptual/tuningdb.rst - # The volumes above cache the MIOpen shape files and user database for subsequent runs + # MIOPEN_FIND_MODE=2 is set by default in the Dockerfile. It reuses the + # on-disk find DB and skips the expensive per-process kernel search. + # The named volumes above persist that cache across runs. + # See: https://github.com/ROCm/MIOpen/blob/develop/docs/conceptual/tuningdb.rst # - # Steps: - # 1. Run Kokoro once with the following environment variables set: + # To pre-populate the cache for best-case latency (recommended for RDNA 2+): + # 1. Override the env to force an exhaustive search: # - MIOPEN_FIND_MODE=3 # - MIOPEN_FIND_ENFORCE=3 - # 2. Generate various recordings using sample data (e.g. first couple paragraphs of Dracula); this will be slow - # 3. Comment out/remove the previously set environment variables - # 4. Add the following environment variables to enable caching of model shapes: - # - MIOPEN_FIND_MODE=2 - # 5. Restart the container and run Kokoro again, it should be much faster + # 2. Run docker/rocm/warmup_miopen.py inside the container, or generate + # audio for varied input lengths (e.g. a few paragraphs of Dracula). + # 3. Remove the overrides. The default (FIND_MODE=2) will reuse the cache. + +volumes: + miopen_config: + miopen_cache: diff --git a/docker/rocm/warmup_miopen.py b/docker/rocm/warmup_miopen.py new file mode 100644 index 00000000..dfc438b5 --- /dev/null +++ b/docker/rocm/warmup_miopen.py @@ -0,0 +1,85 @@ +"""MIOpen kernel warmup for Kokoro on ROCm. + +Kokoro's tensor shape varies with phoneme count, and MIOpen compiles a +unique kernel per shape. Without a populated find DB each new length pays +a 5-60s search. This sweeps lengths 1..340 to warm the cache; ~2 hours on +Strix Halo, runs once per ROCm/PyTorch upgrade, survives reboots. + +Opt-in and unverified outside @realugbun's Strix Halo iGPU (recipe from +#454); reports from other hardware welcome. + +Run inside the container with FIND_MODE overridden so MIOpen performs the +search (image default is FIND_MODE=2, which reuses the on-disk cache): + + docker exec -it bash -lc '\ + MIOPEN_FIND_MODE=3 MIOPEN_FIND_ENFORCE=3 \ + python /app/docker/rocm/warmup_miopen.py' +""" + +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path + +import torch +from kokoro import KModel + +APP_ROOT = Path(os.environ.get("KOKORO_APP_ROOT", "/app")) +MODEL_DIR = APP_ROOT / "api/src/models/v1_0" +VOICE_PATH = APP_ROOT / "api/src/voices/v1_0/af_heart.pt" +MAX_PHONEMES = int(os.environ.get("WARMUP_MAX_PHONEMES", "340")) + + +def main() -> int: + if not torch.cuda.is_available(): + print("CUDA/HIP device not available; aborting.", file=sys.stderr) + return 1 + + find_mode = os.environ.get("MIOPEN_FIND_MODE", "") + if find_mode == "2": + print( + "WARNING: MIOPEN_FIND_MODE=2 is set. The warmup needs MODE=3 to " + "actually run the kernel search. Re-run with " + "MIOPEN_FIND_MODE=3 MIOPEN_FIND_ENFORCE=3.", + file=sys.stderr, + ) + + print(f"[{time.strftime('%H:%M:%S')}] Loading model on GPU...", flush=True) + model = KModel( + config=str(MODEL_DIR / "config.json"), + model=str(MODEL_DIR / "kokoro-v1_0.pth"), + ).eval().cuda() + voice = torch.load(VOICE_PATH, weights_only=True) + + print(f"[{time.strftime('%H:%M:%S')}] Warming lengths 1..{MAX_PHONEMES}...", flush=True) + total = 0.0 + for n in range(1, MAX_PHONEMES + 1): + ps = ("a " * ((n + 1) // 2))[:n] + ref_s = voice[min(len(ps), 509)] + torch.cuda.synchronize() + t0 = time.time() + try: + model(ps, ref_s, speed=1) + except Exception as e: + print(f"ERROR at n={n}: {e}", file=sys.stderr) + continue + torch.cuda.synchronize() + elapsed = time.time() - t0 + total += elapsed + if n % 10 == 0: + print( + f"[{time.strftime('%H:%M:%S')}] {n:3d}/{MAX_PHONEMES} | " + f"this={elapsed:5.1f}s | total={total / 60:5.1f}m", + flush=True, + ) + + print(f"\nWarmup complete in {total / 60:.1f} minutes") + print("Restart the container (or unset the FIND_MODE override) so the " + "default MIOPEN_FIND_MODE=2 picks up the populated cache.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 291d4d5ab2f5e6cd0017022fd191f8765b908d71 Mon Sep 17 00:00:00 2001 From: remsky Date: Mon, 25 May 2026 00:34:43 -0600 Subject: [PATCH 33/45] chore(release): bump and minor docs, release notes --- .github/workflows/release.yml | 2 ++ CHANGELOG.md | 2 +- README.md | 7 +++++++ VERSION | 2 +- charts/kokoro-fastapi/Chart.yaml | 4 ++-- docker/docker-compose.test.yml | 2 +- pyproject.toml | 2 +- 7 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0b24ffe3..c524245c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -286,6 +286,8 @@ jobs: tag_name: ${{ needs.prepare-release.outputs.version_tag }} target_commitish: ${{ needs.prepare-release.outputs.source_ref }} name: Release ${{ needs.prepare-release.outputs.version_tag }} + body: | + Curated summary: [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/${{ needs.prepare-release.outputs.version_tag }}/CHANGELOG.md) generate_release_notes: true draft: false prerelease: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f9530c7..2fe7d5b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Notable changes to this project will be documented in this file. Per-PR attribution and contributor credits are published automatically on the corresponding GitHub release page; this file is the curated, human-readable summary. -## [v0.4.0] - Unreleased +## [v0.4.0] - 2026-05-24 ### Added - GPU image variants for Blackwell / RTX 50-series (`:latest-cu128`, `:vX.Y.Z-cu128`, amd64 only) with PyTorch cu128 wheels (#443). Default `:latest` and new `:latest-cu126` alias stay on cu126 for Maxwell/Pascal compatibility. - Integration test suite (`api/tests/integration/`, opt-in `integration` marker) and a `tts-api-test-client` image that round-trips speech through faster-whisper against a live server. Run via `docker/docker-compose.test.yml`. diff --git a/README.md b/README.md index 9ccc503e..5d5f9161 100644 --- a/README.md +++ b/README.md @@ -665,6 +665,13 @@ Visit [NVIDIA Container Toolkit installation](https://docs.nvidia.com/datacenter
+
+WAV duration reported as nonsense in some readers + +WAV responses ship with streaming-sentinel (`0xFFFFFFFF`) size fields in the header. Most readers (`soundfile`, `pydub`/ffmpeg, browsers, OS players) handle this fine. Python's stdlib `wave` does not, and reports a bogus duration. Use `soundfile.info(path).duration` or `ffprobe` for exact length. + +
+ ## Project
diff --git a/VERSION b/VERSION index a57bda5d..1d0ba9ea 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0-rc1 +0.4.0 diff --git a/charts/kokoro-fastapi/Chart.yaml b/charts/kokoro-fastapi/Chart.yaml index ed6f6753..0ded29e0 100644 --- a/charts/kokoro-fastapi/Chart.yaml +++ b/charts/kokoro-fastapi/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: kokoro-fastapi description: A Helm chart for deploying the Kokoro FastAPI TTS service to Kubernetes type: application -version: 0.3.0 -appVersion: "0.3.0" +version: 0.4.0 +appVersion: "0.4.0" keywords: - tts diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml index 7cc99d1a..58669ca2 100644 --- a/docker/docker-compose.test.yml +++ b/docker/docker-compose.test.yml @@ -9,7 +9,7 @@ name: kokoro-fastapi-test # --abort-on-container-exit --exit-code-from test-client # # Against a published server image (e.g. release verification): -# SERVER_IMAGE=ghcr.io/remsky/kokoro-fastapi-cpu:v0.3.0 \ +# SERVER_IMAGE=ghcr.io/remsky/kokoro-fastapi-cpu:v0.4.0 \ # docker compose -f docker/docker-compose.test.yml up \ # --abort-on-container-exit --exit-code-from test-client # diff --git a/pyproject.toml b/pyproject.toml index 6b49b31e..75ccd45d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kokoro-fastapi" -version = "0.3.0" +version = "0.4.0" description = "FastAPI TTS Service" readme = "README.md" requires-python = ">=3.10" From 3f64dc26081dcb79e4d04ecb3d88da263be76964 Mon Sep 17 00:00:00 2001 From: remsky Date: Tue, 26 May 2026 23:45:07 -0600 Subject: [PATCH 34/45] fix(webui): bounded buffer to resolve 10 minute crashout, player state short ciruit on no-up updates to preserve responsiveness on long sessions. (#471) --- .gitignore | 3 + CHANGELOG.md | 8 + VERSION | 2 +- package-lock.json | 75 ++++++++ package.json | 19 ++ playwright.config.mjs | 15 ++ web/src/App.js | 3 + web/src/components/PlayerControls.js | 17 +- web/src/components/WaveVisualizer.js | 9 +- web/src/services/AudioService.js | 233 +++++++++++++++++------ web/src/state/PlayerState.js | 16 +- web/tests/e2e/fixtures/static-server.mjs | 52 +++++ web/tests/e2e/long-playback.spec.mjs | 106 +++++++++++ web/tests/unit/audio-service.test.mjs | 18 ++ web/tests/unit/index.test.mjs | 2 + web/tests/unit/player-controls.test.mjs | 113 +++++++++++ 16 files changed, 624 insertions(+), 67 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.mjs create mode 100644 web/tests/e2e/fixtures/static-server.mjs create mode 100644 web/tests/e2e/long-playback.spec.mjs create mode 100644 web/tests/unit/audio-service.test.mjs create mode 100644 web/tests/unit/index.test.mjs create mode 100644 web/tests/unit/player-controls.test.mjs diff --git a/.gitignore b/.gitignore index 40746abe..eb94894f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ build/ # Environment # .env .venv/ +node_modules/ env/ venv/ ENV/ @@ -87,3 +88,5 @@ pyproject.toml.bkp # Local scratch notes, scan outputs, anything not meant to ship .local/ examples/assorted_checks/test_silence/out/* +playwright-report/ +test-results/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fe7d5b1..a8ed72b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ Notable changes to this project will be documented in this file. Per-PR attribution and contributor credits are published automatically on the corresponding GitHub release page; this file is the curated, human-readable summary. +## [Unreleased] +### Fixed +- Web UI long-playback bugfix around the 10-minute mark; in-browser audio buffer is now bounded ahead of `currentTime` with trailing eviction behind it, so long generations stop overflowing the SourceBuffer. +- Web UI stays responsive on extended sessions; waveform animation is transition-gated and `PlayerState` short-circuits no-op updates, so controls don't drift into lag after 10+ minutes of playback. + +### Notes +- Scrubbing may not be fully not supported in current state on MP3 streamed playback. WAV etc, plays back fine on completion. + ## [v0.4.0] - 2026-05-24 ### Added - GPU image variants for Blackwell / RTX 50-series (`:latest-cu128`, `:vX.Y.Z-cu128`, amd64 only) with PyTorch cu128 wheels (#443). Default `:latest` and new `:latest-cu126` alias stay on cu126 for Maxwell/Pascal compatibility. diff --git a/VERSION b/VERSION index 1d0ba9ea..20e5763a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +0.4.1-rc1 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..e62461e6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,75 @@ +{ + "name": "Kokoro-FastAPI", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@playwright/test": "^1.57.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..d57c075f --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "type": "module", + "scripts": { + "test:web": "node web/tests/unit/index.test.mjs", + "test:e2e": "playwright test", + "gpu:build": "docker compose -f docker/gpu/docker-compose.yml build", + "gpu:up": "docker compose -f docker/gpu/docker-compose.yml up", + "gpu:down": "docker compose -f docker/gpu/docker-compose.yml down", + "cpu:build": "docker compose -f docker/cpu/docker-compose.yml build", + "cpu:up": "docker compose -f docker/cpu/docker-compose.yml up", + "cpu:down": "docker compose -f docker/cpu/docker-compose.yml down", + "rocm:build": "docker compose -f docker/rocm/docker-compose.yml build", + "rocm:up": "docker compose -f docker/rocm/docker-compose.yml up", + "rocm:down": "docker compose -f docker/rocm/docker-compose.yml down" + }, + "devDependencies": { + "@playwright/test": "^1.57.0" + } +} diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 00000000..3e6c8e63 --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,15 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './web/tests/e2e', + timeout: 30_000, + use: { + baseURL: 'http://127.0.0.1:4173', + }, + webServer: { + command: 'node web/tests/e2e/fixtures/static-server.mjs', + url: 'http://127.0.0.1:4173', + reuseExistingServer: true, + timeout: 10_000, + }, +}); diff --git a/web/src/App.js b/web/src/App.js index 9a83f192..53a9cb57 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -226,6 +226,9 @@ export class App { const voice = this.voiceService.getSelectedVoiceString(); const speed = this.playerState.getState().speed; + this.playerState.setReady(false); + this.playerState.setPlaying(false); + this.playerState.setTime(0, 0); this.setGenerating(true); this._playbackFailed = false; this.elements.downloadBtn.classList.remove('ready'); diff --git a/web/src/components/PlayerControls.js b/web/src/components/PlayerControls.js index 3daf33c8..7f5ca3fc 100644 --- a/web/src/components/PlayerControls.js +++ b/web/src/components/PlayerControls.js @@ -19,6 +19,10 @@ export class PlayerControls { } formatTime(secs) { + if (!Number.isFinite(secs) || secs < 0) { + return '0:00'; + } + const minutes = Math.floor(secs / 60); const seconds = Math.floor(secs % 60); return `${minutes}:${seconds.toString().padStart(2, '0')}`; @@ -47,7 +51,7 @@ export class PlayerControls { `${this.formatTime(currentTime)} / ${this.formatTime(duration || 0)}`; // Update seek slider - if (duration > 0 && !this.elements.seekSlider.dragging) { + if (Number.isFinite(duration) && duration > 0 && !this.elements.seekSlider.dragging) { this.elements.seekSlider.value = (currentTime / duration) * 100; } @@ -123,6 +127,11 @@ export class PlayerControls { this.stopTimeUpdate(); }); + this.audioService.addEventListener('ready', () => { + this.playerState.setReady(true); + this.updateTimeDisplay(); + }); + // Initial time display this.updateTimeDisplay(); } @@ -133,8 +142,8 @@ export class PlayerControls { updateControls(state) { // Update button states - this.elements.playPauseBtn.disabled = !state.duration && !state.isGenerating; - this.elements.seekSlider.disabled = !state.duration; + this.elements.playPauseBtn.disabled = !state.isReady && !state.isGenerating; + this.elements.seekSlider.disabled = !state.isReady || !Number.isFinite(state.duration) || state.duration <= 0; this.elements.cancelBtn.style.display = state.isGenerating ? 'block' : 'none'; // Update volume and speed if changed externally @@ -165,4 +174,4 @@ export class PlayerControls { } } -export default PlayerControls; \ No newline at end of file +export default PlayerControls; diff --git a/web/src/components/WaveVisualizer.js b/web/src/components/WaveVisualizer.js index b4df33d8..55ee32c8 100644 --- a/web/src/components/WaveVisualizer.js +++ b/web/src/components/WaveVisualizer.js @@ -40,6 +40,7 @@ export class WaveVisualizer { } setupStateSubscription() { + this.wasPlaying = false; this.playerState.subscribe(state => { // Handle generation progress if (state.isGenerating) { @@ -53,12 +54,14 @@ export class WaveVisualizer { }, 500); } - // Only animate when playing, stop otherwise - if (state.isPlaying) { + // SiriWave.start() is not idempotent — each call spawns a new RAF + // loop. Only call start/stop on isPlaying transitions. + if (state.isPlaying && !this.wasPlaying) { this.wave.start(); - } else { + } else if (!state.isPlaying && this.wasPlaying) { this.wave.stop(); } + this.wasPlaying = state.isPlaying; }); } diff --git a/web/src/services/AudioService.js b/web/src/services/AudioService.js index 63515410..d799a279 100644 --- a/web/src/services/AudioService.js +++ b/web/src/services/AudioService.js @@ -11,9 +11,13 @@ export class AudioService { this.textLength = 0; this.shouldAutoplay = false; this.CHARS_PER_CHUNK = 150; + this.MAX_LEAD_SECONDS = 60; this.serverDownloadPath = null; this.pendingOperations = []; this.objectUrl = null; + this.chunkQueue = []; + this.streamFinished = false; + this.feederWakeup = null; } supportsMSEMp3() { @@ -25,6 +29,21 @@ export class AudioService { ); } + shouldUseMseStream(responseFormat, canStreamMp3) { + return responseFormat === 'mp3' && canStreamMp3; + } + + attachAudioReadinessEvents() { + if (!this.audio) { + return; + } + + const dispatchReady = () => this.dispatchEvent('ready'); + this.audio.addEventListener('loadedmetadata', dispatchReady); + this.audio.addEventListener('durationchange', dispatchReady); + this.audio.addEventListener('canplay', dispatchReady); + } + async streamAudio(text, voice, speed, onProgress) { try { const canStreamMp3 = this.supportsMSEMp3(); @@ -43,7 +62,7 @@ export class AudioService { const estimatedChunks = Math.max(1, Math.ceil(this.textLength / this.CHARS_PER_CHUNK)); const responseFormat = document.getElementById('format-select').value || 'mp3'; - const canUseMseStream = responseFormat === 'mp3' && canStreamMp3; + const canUseMseStream = this.shouldUseMseStream(responseFormat, canStreamMp3); const apiUrl = await config.getApiUrl('/v1/audio/speech'); const response = await fetch(apiUrl, { @@ -118,8 +137,10 @@ export class AudioService { const blobType = response.headers.get('content-type') || 'audio/mpeg'; const blob = new Blob(chunks, { type: blobType }); this.audio = new Audio(); + this.attachAudioReadinessEvents(); this.objectUrl = URL.createObjectURL(blob); this.audio.src = this.objectUrl; + this.audio.load(); this.audio.addEventListener('error', () => { console.error('Audio error (block mode):', this.audio?.error); @@ -130,7 +151,7 @@ export class AudioService { this.dispatchEvent('ended'); }); - this.audio.addEventListener('canplaythrough', () => { + this.audio.addEventListener('canplay', () => { if (this.shouldAutoplay) { this.play(); } @@ -151,6 +172,7 @@ export class AudioService { } this.audio = new Audio(); + this.attachAudioReadinessEvents(); this.mediaSource = new MediaSource(); this.objectUrl = URL.createObjectURL(this.mediaSource); this.audio.src = this.objectUrl; @@ -183,8 +205,17 @@ export class AudioService { } async processStream(stream, response, onProgress, estimatedChunks) { + this.chunkQueue = []; + this.streamFinished = false; + this.feederWakeup = null; + + const feederPromise = this.runFeeder().catch((err) => { + if (err?.name !== 'AbortError') { + console.warn('Feeder error:', err); + } + }); + const reader = stream.getReader(); - let hasStartedPlaying = false; let receivedChunks = 0; try { @@ -204,22 +235,12 @@ export class AudioService { Object.keys(headers).join(', ')); } - if (this.mediaSource && this.mediaSource.readyState === 'open') { - this.mediaSource.endOfStream(); - } + this.streamFinished = true; + this.wakeFeeder(); onProgress?.(estimatedChunks, estimatedChunks); this.dispatchEvent('complete'); - if ( - this.shouldAutoplay && - !hasStartedPlaying && - this.sourceBuffer && - this.sourceBuffer.buffered.length > 0 - ) { - setTimeout(() => this.play(), 100); - } - setTimeout(() => { this.dispatchEvent('downloadReady'); }, 800); @@ -229,57 +250,122 @@ export class AudioService { receivedChunks++; onProgress?.(receivedChunks, estimatedChunks); + this.chunkQueue.push(value); + this.wakeFeeder(); + } + } catch (error) { + this.streamFinished = true; + this.wakeFeeder(); + if (error.name !== 'AbortError') { + throw error; + } + } + } - try { - if (this.audio?.error) { - console.error('Audio error detected:', this.audio.error); - continue; + wakeFeeder() { + if (this.feederWakeup) { + const resolve = this.feederWakeup; + this.feederWakeup = null; + resolve(); + } + } + + waitForFeederSignal(timeoutMs) { + return new Promise((resolve) => { + this.feederWakeup = resolve; + if (timeoutMs) { + setTimeout(() => { + if (this.feederWakeup === resolve) { + this.feederWakeup = null; + resolve(); } + }, timeoutMs); + } + }); + } - if (this.sourceBuffer?.buffered.length > 0) { - const currentTime = this.audio.currentTime; - const start = this.sourceBuffer.buffered.start(0); + async runFeeder() { + let hasStartedPlaying = false; - if (currentTime - start > 30) { - const removeEnd = Math.max(start, currentTime - 15); - if (removeEnd > start) { - await this.removeBufferRange(start, removeEnd); - } - } + while (true) { + if (!this.audio || !this.sourceBuffer || !this.mediaSource) { + return; + } + if (this.streamFinished && this.chunkQueue.length === 0) { + if (this.mediaSource.readyState === 'open') { + try { + this.mediaSource.endOfStream(); + } catch (e) { + console.warn('endOfStream error:', e); } + } + return; + } + if (this.chunkQueue.length === 0) { + await this.waitForFeederSignal(); + continue; + } - await this.appendChunk(value); + const currentTime = this.audio.currentTime || 0; + const buffered = this.sourceBuffer.buffered; + + // Leading-edge backpressure: hold off if we already have plenty queued + // ahead of currentTime. Keeps MSE buffer bounded so long generations + // (>10 min) don't hit QuotaExceededError. + if (buffered.length > 0) { + const leadingEdge = buffered.end(buffered.length - 1); + if (leadingEdge - currentTime > this.MAX_LEAD_SECONDS) { + await this.waitForFeederSignal(250); + continue; + } + } - if (!hasStartedPlaying && this.sourceBuffer?.buffered.length > 0) { - hasStartedPlaying = true; - if (this.shouldAutoplay) { - setTimeout(() => this.play(), 100); - } + // Trailing eviction: drop audio more than 30s behind currentTime. + if (buffered.length > 0) { + const start = buffered.start(0); + if (currentTime - start > 30) { + const removeEnd = Math.max(start, currentTime - 15); + if (removeEnd > start) { + await this.removeBufferRange(start, removeEnd); } - } catch (error) { - if (error.name === 'QuotaExceededError') { - if (this.sourceBuffer?.buffered.length > 0) { - const currentTime = this.audio.currentTime; - const start = this.sourceBuffer.buffered.start(0); - const removeEnd = Math.max(start, currentTime - 5); - if (removeEnd > start) { - await this.removeBufferRange(start, removeEnd); - try { - await this.appendChunk(value); - } catch (retryError) { - console.warn('Buffer error after cleanup:', retryError); - } - } + } + } + + const chunk = this.chunkQueue.shift(); + try { + if (this.audio?.error) { + console.error('Audio error detected:', this.audio.error); + continue; + } + + await this.appendChunk(chunk); + this.dispatchEvent('ready'); + + if (!hasStartedPlaying && this.sourceBuffer?.buffered.length > 0) { + hasStartedPlaying = true; + if (this.shouldAutoplay) { + setTimeout(() => this.play(), 100); + } + } + } catch (error) { + if (error.name === 'QuotaExceededError') { + this.chunkQueue.unshift(chunk); + const buf = this.sourceBuffer?.buffered; + if (buf && buf.length > 0) { + const start = buf.start(0); + const removeEnd = Math.max(start, (this.audio?.currentTime || 0) - 5); + if (removeEnd > start) { + await this.removeBufferRange(start, removeEnd); } } else { - console.warn('Buffer error:', error); + return; } + } else if (error?.name === 'AbortError') { + return; + } else { + console.warn('Buffer error:', error); } } - } catch (error) { - if (error.name !== 'AbortError') { - throw error; - } } } @@ -295,12 +381,25 @@ export class AudioService { return new Promise((resolve) => { const doRemove = () => { + const sourceBuffer = this.sourceBuffer; + if (!sourceBuffer || !this.mediaSource || this.mediaSource.readyState !== 'open') { + resolve(); + return; + } + + const onUpdateEnd = () => { + sourceBuffer.removeEventListener('updateend', onUpdateEnd); + resolve(); + }; + try { - this.sourceBuffer.remove(start, end); + sourceBuffer.addEventListener('updateend', onUpdateEnd, { once: true }); + sourceBuffer.remove(start, end); } catch (e) { console.warn('Error removing buffer:', e); + sourceBuffer.removeEventListener('updateend', onUpdateEnd); + resolve(); } - resolve(); }; if (this.sourceBuffer.updating) { @@ -375,7 +474,7 @@ export class AudioService { } play() { - if (this.audio && this.audio.readyState >= 2 && !this.audio.error) { + if (this.audio && !this.audio.error) { const playPromise = this.audio.play(); if (playPromise) { playPromise.catch(error => { @@ -458,6 +557,18 @@ export class AudioService { } } + rejectPendingOperations(reason) { + const ops = this.pendingOperations; + this.pendingOperations = []; + ops.forEach((op) => { + try { + op.reject(reason); + } catch (e) { + // ignore + } + }); + } + cancel() { if (this.controller) { this.controller.abort(); @@ -480,7 +591,10 @@ export class AudioService { this.mediaSource = null; this.sourceBuffer = null; this.serverDownloadPath = null; - this.pendingOperations = []; + this.rejectPendingOperations(new Error('AudioService cancelled')); + this.chunkQueue = []; + this.streamFinished = true; + this.wakeFeeder(); this.revokeObjectUrl(); } @@ -507,7 +621,10 @@ export class AudioService { this.mediaSource = null; this.sourceBuffer = null; this.serverDownloadPath = null; - this.pendingOperations = []; + this.rejectPendingOperations(new Error('AudioService cleanup')); + this.chunkQueue = []; + this.streamFinished = true; + this.wakeFeeder(); this.revokeObjectUrl(); } diff --git a/web/src/state/PlayerState.js b/web/src/state/PlayerState.js index a2ea7b1e..3313bec7 100644 --- a/web/src/state/PlayerState.js +++ b/web/src/state/PlayerState.js @@ -8,6 +8,7 @@ export class PlayerState { volume: 1, speed: 1, progress: 0, + isReady: false, error: null }; this.listeners = new Set(); @@ -23,6 +24,14 @@ export class PlayerState { } setState(updates) { + let changed = false; + for (const key in updates) { + if (updates[key] !== this.state[key]) { + changed = true; + break; + } + } + if (!changed) return; this.state = { ...this.state, ...updates @@ -43,6 +52,10 @@ export class PlayerState { this.setState({ progress }); } + setReady(isReady) { + this.setState({ isReady }); + } + setTime(currentTime, duration) { this.setState({ currentTime, duration }); } @@ -74,6 +87,7 @@ export class PlayerState { currentTime: 0, duration: 0, progress: 0, + isReady: false, error: null, speed: currentSpeed, volume: currentVolume @@ -85,4 +99,4 @@ export class PlayerState { } } -export default PlayerState; \ No newline at end of file +export default PlayerState; diff --git a/web/tests/e2e/fixtures/static-server.mjs b/web/tests/e2e/fixtures/static-server.mjs new file mode 100644 index 00000000..04dd11c4 --- /dev/null +++ b/web/tests/e2e/fixtures/static-server.mjs @@ -0,0 +1,52 @@ +import { createReadStream, statSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { extname, join, normalize, resolve } from 'node:path'; + +const port = Number(process.env.PLAYWRIGHT_STATIC_PORT || 4173); +const root = resolve('web'); + +const contentTypes = { + '.css': 'text/css', + '.html': 'text/html', + '.js': 'text/javascript', + '.svg': 'image/svg+xml', +}; + +function resolveRequestPath(url) { + const pathname = new URL(url, `http://127.0.0.1:${port}`).pathname; + const relativePath = pathname === '/' ? 'index.html' : pathname.slice(1); + const requested = resolve(root, normalize(relativePath)); + + if (!requested.startsWith(root)) { + return null; + } + + return requested; +} + +const server = createServer((request, response) => { + const filePath = resolveRequestPath(request.url); + if (!filePath) { + response.writeHead(403); + response.end(); + return; + } + + try { + const stat = statSync(filePath); + if (!stat.isFile()) { + throw new Error('Not a file'); + } + + response.writeHead(200, { + 'Content-Length': stat.size, + 'Content-Type': contentTypes[extname(filePath)] || 'application/octet-stream', + }); + createReadStream(filePath).pipe(response); + } catch { + response.writeHead(404); + response.end(); + } +}); + +server.listen(port, '127.0.0.1'); diff --git a/web/tests/e2e/long-playback.spec.mjs b/web/tests/e2e/long-playback.spec.mjs new file mode 100644 index 00000000..58d2f52a --- /dev/null +++ b/web/tests/e2e/long-playback.spec.mjs @@ -0,0 +1,106 @@ +import { expect, test } from '@playwright/test'; + +function longText() { + return Array.from({ length: 2000 }, (_, index) => `word${index}`).join(' '); +} + +test('long MP3 generation uses MediaSource streaming', async ({ page }) => { + await page.addInitScript(() => { + class MockSourceBuffer extends EventTarget { + constructor() { + super(); + this.updating = false; + this.mode = 'segments'; + this.buffered = { + length: 0, + start: () => 0, + end: () => 0, + }; + } + + appendBuffer() { + window.__sourceBufferAppends = (window.__sourceBufferAppends || 0) + 1; + this.updating = true; + setTimeout(() => { + this.updating = false; + this.dispatchEvent(new Event('updateend')); + }, 0); + } + + remove() { + this.updating = true; + setTimeout(() => { + this.updating = false; + this.dispatchEvent(new Event('updateend')); + }, 0); + } + } + + class MockMediaSource extends EventTarget { + constructor() { + super(); + window.__mediaSourceConstructed = (window.__mediaSourceConstructed || 0) + 1; + this.readyState = 'closed'; + setTimeout(() => { + this.readyState = 'open'; + this.dispatchEvent(new Event('sourceopen')); + }, 0); + } + + static isTypeSupported() { + return true; + } + + addSourceBuffer() { + window.__sourceBufferCreated = (window.__sourceBufferCreated || 0) + 1; + return new MockSourceBuffer(); + } + + endOfStream() { + this.readyState = 'ended'; + } + } + + window.__mediaSourceConstructed = 0; + window.__sourceBufferCreated = 0; + window.__sourceBufferAppends = 0; + Object.defineProperty(window, 'MediaSource', { + configurable: true, + value: MockMediaSource, + }); + }); + + await page.route('**/web/config', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ root_path: '', version: 'test' }), + }); + }); + + await page.route('**/v1/audio/voices', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ voices: [{ id: 'af_heart', name: 'af_heart' }] }), + }); + }); + + let speechRequestBody = null; + await page.route('**/v1/audio/speech', async (route) => { + speechRequestBody = JSON.parse(route.request().postData()); + await route.fulfill({ + contentType: 'audio/mpeg', + headers: { 'X-Download-Path': '/download/test.mp3' }, + body: Buffer.from([0xff, 0xfb, 0x90, 0x64]), + }); + }); + + await page.goto('/'); + await page.locator('.page-content').fill(longText()); + await page.locator('#generate-btn').click(); + await expect.poll(() => speechRequestBody).not.toBeNull(); + + expect(speechRequestBody.response_format).toBe('mp3'); + expect(speechRequestBody.stream).toBe(true); + await expect.poll(() => page.evaluate(() => window.__mediaSourceConstructed)).toBeGreaterThan(0); + await expect.poll(() => page.evaluate(() => window.__sourceBufferCreated)).toBeGreaterThan(0); +}); diff --git a/web/tests/unit/audio-service.test.mjs b/web/tests/unit/audio-service.test.mjs new file mode 100644 index 00000000..493cc244 --- /dev/null +++ b/web/tests/unit/audio-service.test.mjs @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +const { AudioService } = await import('../../src/services/AudioService.js'); + +test('AudioService streams supported MP3 requests with MediaSource regardless of length', () => { + const service = new AudioService(); + + assert.equal(service.shouldUseMseStream('mp3', true), true); +}); + +test('AudioService does not use MediaSource for unsupported or non-MP3 output', () => { + const service = new AudioService(); + + assert.equal(service.shouldUseMseStream('mp3', false), false); + assert.equal(service.shouldUseMseStream('wav', true), false); + assert.equal(service.shouldUseMseStream('pcm', true), false); +}); diff --git a/web/tests/unit/index.test.mjs b/web/tests/unit/index.test.mjs new file mode 100644 index 00000000..78ac1294 --- /dev/null +++ b/web/tests/unit/index.test.mjs @@ -0,0 +1,2 @@ +import './audio-service.test.mjs'; +import './player-controls.test.mjs'; diff --git a/web/tests/unit/player-controls.test.mjs b/web/tests/unit/player-controls.test.mjs new file mode 100644 index 00000000..91e6ea0c --- /dev/null +++ b/web/tests/unit/player-controls.test.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +class FakeClassList { + constructor() { + this.classes = new Set(); + } + + add(name) { + this.classes.add(name); + } + + remove(name) { + this.classes.delete(name); + } + + toggle(name, force) { + if (force === undefined ? !this.classes.has(name) : force) { + this.classes.add(name); + } else { + this.classes.delete(name); + } + } +} + +class FakeElement { + constructor() { + this.listeners = new Map(); + this.classList = new FakeClassList(); + this.style = {}; + this.disabled = false; + this.value = 0; + this.textContent = ''; + this.dragging = false; + } + + addEventListener(event, callback) { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event).push(callback); + } +} + +class FakeAudioService { + constructor() { + this.listeners = new Map(); + } + + addEventListener(event, callback) { + this.listeners.set(event, callback); + } + + emit(event) { + this.listeners.get(event)?.(); + } + + getCurrentTime() { + return 0; + } + + getDuration() { + return 0; + } + + isPlaying() { + return false; + } + + play() {} + + pause() {} + + seek() {} + + setVolume() {} +} + +function setupDocument() { + const elements = new Map(); + global.document = { + getElementById(id) { + if (!elements.has(id)) { + elements.set(id, new FakeElement()); + } + return elements.get(id); + }, + }; + return elements; +} + +test('PlayerControls enables playback when audio readiness fires without duration', async () => { + const elements = setupDocument(); + const { PlayerState } = await import('../../src/state/PlayerState.js'); + const { PlayerControls } = await import('../../src/components/PlayerControls.js'); + + const audioService = new FakeAudioService(); + const playerState = new PlayerState(); + const controls = new PlayerControls(audioService, playerState); + + playerState.reset(); + const playPauseBtn = elements.get('play-pause-btn'); + const seekSlider = elements.get('seek-slider'); + + assert.equal(playPauseBtn.disabled, true); + + audioService.emit('ready'); + + assert.equal(playPauseBtn.disabled, false); + assert.equal(seekSlider.disabled, true); + + controls.cleanup(); +}); From ff6efaffa380df69b8c7a599d48945f619093376 Mon Sep 17 00:00:00 2001 From: "Andrew T. Marcus" Date: Wed, 3 Jun 2026 02:34:53 -0400 Subject: [PATCH 35/45] feat(api): add POST /dev/unload to release model from GPU VRAM (#474) * feat(api): add POST /dev/unload to release model from GPU VRAM Allows homelab deployments to free GPU memory when the TTS service is idle without stopping the container. The model reloads lazily on the next request. - ModelManager.unload(): acquires lock, calls backend.unload(), nulls _backend, then calls torch.cuda.empty_cache() if CUDA is available - ModelManager.generate(): lazy reinit when _backend is None (calls initialize() + load_model()) instead of raising RuntimeError - POST /dev/unload: 200 on success, 503 if manager not initialised, 500 on unexpected error - TTSService.model_manager annotated as Optional[ModelManager] for correct mypy narrowing at the endpoint - Full test coverage in api/tests/test_model_unload.py (11 tests) Closes #473 (partial) Co-Authored-By: Claude Sonnet 4.6 * fix(model-unload): lazy reload on next request after /dev/unload Add ensure_backend() to ModelManager which reinitialises the backend and reloads the model if /dev/unload was called. All three get_backend() call sites in tts_service now await ensure_backend() first, so the first TTS request after an unload reloads the model automatically rather than raising RuntimeError: Backend not initialized. Co-Authored-By: Claude Sonnet 4.6 * fix(model-unload): add double-checked locking to ensure_backend() Addresses review feedback: the original lazy-reload in generate() ran without a lock, so a burst of requests landing while _backend is None could trigger multiple concurrent initialize()/load_model() calls. - ensure_backend(): fast-path check outside lock, then re-check inside _lock before initializing (double-checked locking pattern) - generate(): routes through ensure_backend() instead of inline check, eliminating the duplicate code path - test_ensure_backend_serializes_concurrent_reloads: 5-way concurrent gather confirms only one initialize/load_model cycle fires Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- api/src/inference/model_manager.py | 26 +++- api/src/routers/development.py | 21 +++ api/src/services/tts_service.py | 9 +- api/tests/test_model_unload.py | 241 +++++++++++++++++++++++++++++ 4 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 api/tests/test_model_unload.py diff --git a/api/src/inference/model_manager.py b/api/src/inference/model_manager.py index eb817ecb..231f1f97 100644 --- a/api/src/inference/model_manager.py +++ b/api/src/inference/model_manager.py @@ -1,7 +1,9 @@ """Kokoro V1 model management.""" +import asyncio from typing import Optional +import torch from loguru import logger from ..core import paths @@ -26,6 +28,7 @@ def __init__(self, config: Optional[ModelConfig] = None): self._config = config or model_config self._backend: Optional[KokoroV1] = None # Explicitly type as KokoroV1 self._device: Optional[str] = None + self._lock = asyncio.Lock() def _determine_device(self) -> str: """Determine device based on settings.""" @@ -98,6 +101,15 @@ async def initialize_with_warmup(self, voice_manager) -> tuple[str, str, int]: except Exception as e: raise RuntimeError(f"Warmup failed: {e}") + async def ensure_backend(self) -> None: + """Reload the backend if it was unloaded via /dev/unload.""" + if self._backend: + return + async with self._lock: + if not self._backend: + await self.initialize() + await self.load_model(self._config.pytorch_kokoro_v1_file) + def get_backend(self) -> BaseModelBackend: """Get initialized backend. @@ -136,8 +148,8 @@ async def generate(self, *args, **kwargs): Raises: RuntimeError: If generation fails """ - if not self._backend: - raise RuntimeError("Backend not initialized") + await self.ensure_backend() + assert self._backend is not None # ensure_backend loaded it or raised try: async for chunk in self._backend.generate(*args, **kwargs): @@ -153,6 +165,16 @@ def unload_all(self) -> None: self._backend.unload() self._backend = None + async def unload(self) -> None: + """Release model from GPU memory. Reloads automatically on next request.""" + async with self._lock: + if self._backend is not None: + self._backend.unload() + self._backend = None + if torch.cuda.is_available(): + torch.cuda.empty_cache() + logger.info("Model unloaded from GPU memory") + @property def current_backend(self) -> str: """Get current backend type.""" diff --git a/api/src/routers/development.py b/api/src/routers/development.py index 8c8ed7e1..b310c81a 100644 --- a/api/src/routers/development.py +++ b/api/src/routers/development.py @@ -411,3 +411,24 @@ async def single_output(): "type": "server_error", }, ) + + +@router.post("/dev/unload") +async def unload_model( + tts_service: TTSService = Depends(get_tts_service), +): + """Release the model from GPU VRAM without stopping the container. + + The model reloads automatically on the next inference request. + Useful for homelab deployments where GPU memory is shared across services. + """ + try: + if tts_service.model_manager is None: + raise HTTPException(status_code=503, detail={"error": "Model manager not initialized"}) + await tts_service.model_manager.unload() + return JSONResponse({"status": "unloaded"}) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error unloading model: {e}") + raise HTTPException(status_code=500, detail={"error": str(e)}) diff --git a/api/src/services/tts_service.py b/api/src/services/tts_service.py index 1ac74557..02de19cc 100644 --- a/api/src/services/tts_service.py +++ b/api/src/services/tts_service.py @@ -15,6 +15,7 @@ from ..core.config import settings from ..inference.base import AudioChunk from ..inference.kokoro_v1 import KokoroV1 +from ..inference.model_manager import ModelManager from ..inference.model_manager import get_manager as get_model_manager from ..inference.voice_manager import get_manager as get_voice_manager from ..structures.schemas import NormalizationOptions @@ -33,7 +34,7 @@ class TTSService: def __init__(self, output_dir: str = None): """Initialize service.""" self.output_dir = output_dir - self.model_manager = None + self.model_manager: Optional[ModelManager] = None self._voice_manager = None @classmethod @@ -87,7 +88,7 @@ async def _process_chunk( if not tokens and not chunk_text: return - # Get backend + await self.model_manager.ensure_backend() backend = self.model_manager.get_backend() # Generate audio using pre-warmed model @@ -272,7 +273,7 @@ async def generate_audio_stream( chunk_index = 0 current_offset = 0.0 try: - # Get backend + await self.model_manager.ensure_backend() backend = self.model_manager.get_backend() # Get voice path, handling combined voices @@ -465,7 +466,7 @@ async def generate_from_phonemes( """ start_time = time.time() try: - # Get backend and voice path + await self.model_manager.ensure_backend() backend = self.model_manager.get_backend() voice_name, voice_path = await self._get_voices_path(voice) diff --git a/api/tests/test_model_unload.py b/api/tests/test_model_unload.py new file mode 100644 index 00000000..6b31489a --- /dev/null +++ b/api/tests/test_model_unload.py @@ -0,0 +1,241 @@ +"""Tests for ModelManager.unload(), lazy reinit in generate(), and POST /dev/unload.""" + +import asyncio +from contextlib import contextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +import numpy as np +import pytest +from fastapi.testclient import TestClient + +from api.src.inference.base import AudioChunk +from api.src.inference.model_manager import ModelManager +from api.src.main import app +from api.src.routers.development import get_tts_service +from api.src.services.tts_service import TTSService + +client = TestClient(app) + + +@contextmanager +def override_tts_service(service): + """Override the get_tts_service FastAPI dependency for the duration of the block.""" + async def _override(): + return service + + app.dependency_overrides[get_tts_service] = _override + try: + yield + finally: + app.dependency_overrides.pop(get_tts_service, None) + + +# --------------------------------------------------------------------------- +# ModelManager unit tests +# --------------------------------------------------------------------------- + + +def test_manager_init_creates_lock(): + manager = ModelManager() + assert isinstance(manager._lock, asyncio.Lock) + + +@pytest.mark.asyncio +async def test_unload_clears_backend(): + manager = ModelManager() + mock_backend = MagicMock() + manager._backend = mock_backend + + with patch("api.src.inference.model_manager.torch") as mock_torch: + mock_torch.cuda.is_available.return_value = False + await manager.unload() + + mock_backend.unload.assert_called_once() + assert manager._backend is None + + +@pytest.mark.asyncio +async def test_unload_when_already_none_is_noop(): + manager = ModelManager() + assert manager._backend is None + + with patch("api.src.inference.model_manager.torch") as mock_torch: + mock_torch.cuda.is_available.return_value = False + await manager.unload() # must not raise + + assert manager._backend is None + + +@pytest.mark.asyncio +async def test_unload_calls_cuda_empty_cache_when_available(): + manager = ModelManager() + manager._backend = MagicMock() + + with patch("api.src.inference.model_manager.torch") as mock_torch: + mock_torch.cuda.is_available.return_value = True + await manager.unload() + + mock_torch.cuda.empty_cache.assert_called_once() + + +@pytest.mark.asyncio +async def test_unload_skips_cuda_empty_cache_when_unavailable(): + manager = ModelManager() + manager._backend = MagicMock() + + with patch("api.src.inference.model_manager.torch") as mock_torch: + mock_torch.cuda.is_available.return_value = False + await manager.unload() + + mock_torch.cuda.empty_cache.assert_not_called() + + +@pytest.mark.asyncio +async def test_ensure_backend_serializes_concurrent_reloads(): + """Concurrent callers when _backend is None should trigger only one load cycle.""" + manager = ModelManager() + assert manager._backend is None + + mock_backend = MagicMock() + init_count = 0 + load_count = 0 + + async def fake_initialize(): + nonlocal init_count + init_count += 1 + await asyncio.sleep(0) # yield so other tasks can attempt entry + manager._backend = mock_backend + + async def fake_load(path): + nonlocal load_count + load_count += 1 + + with ( + patch.object(manager, "initialize", side_effect=fake_initialize), + patch.object(manager, "load_model", side_effect=fake_load), + ): + await asyncio.gather(*[manager.ensure_backend() for _ in range(5)]) + + assert init_count == 1 + assert load_count == 1 + + +@pytest.mark.asyncio +async def test_generate_lazy_reinit_when_backend_none(): + """generate() initializes backend lazily when _backend is None.""" + manager = ModelManager() + assert manager._backend is None + + mock_backend = MagicMock() + audio_chunk = AudioChunk(np.zeros(10, dtype=np.float32)) + + async def fake_generate(*args, **kwargs): + yield audio_chunk + + mock_backend.generate = fake_generate + + async def fake_initialize(): + manager._backend = mock_backend + + with ( + patch.object(manager, "initialize", side_effect=fake_initialize) as mock_init, + patch.object(manager, "load_model", new_callable=AsyncMock) as mock_load, + ): + chunks = [] + async for chunk in manager.generate("hello", ("voice", "/path/voice.pt")): + chunks.append(chunk) + + mock_init.assert_called_once() + mock_load.assert_called_once_with(manager._config.pytorch_kokoro_v1_file) + assert len(chunks) == 1 + assert chunks[0] is audio_chunk + + +@pytest.mark.asyncio +async def test_generate_skips_reinit_when_backend_set(): + """generate() does not call initialize/load_model when backend already exists.""" + manager = ModelManager() + mock_backend = MagicMock() + audio_chunk = AudioChunk(np.zeros(10, dtype=np.float32)) + + async def fake_generate(*args, **kwargs): + yield audio_chunk + + mock_backend.generate = fake_generate + manager._backend = mock_backend + + with ( + patch.object(manager, "initialize", new_callable=AsyncMock) as mock_init, + patch.object(manager, "load_model", new_callable=AsyncMock) as mock_load, + ): + chunks = [] + async for chunk in manager.generate("hello", ("voice", "/path/voice.pt")): + chunks.append(chunk) + + mock_init.assert_not_called() + mock_load.assert_not_called() + assert len(chunks) == 1 + + +# --------------------------------------------------------------------------- +# POST /dev/unload endpoint tests +# --------------------------------------------------------------------------- + + +def _mock_service(manager=None): + """Build a TTSService-shaped mock with the given model_manager.""" + service = MagicMock(spec=TTSService) + service.model_manager = manager + return service + + +def test_unload_endpoint_returns_200(): + mock_manager = AsyncMock() + mock_manager.unload = AsyncMock() + service = _mock_service(manager=mock_manager) + + with override_tts_service(service): + response = client.post("/dev/unload") + + assert response.status_code == 200 + assert response.json() == {"status": "unloaded"} + mock_manager.unload.assert_called_once() + + +def test_unload_endpoint_idempotent(): + """Calling /dev/unload twice both succeed — unload is a no-op when already clear.""" + mock_manager = AsyncMock() + mock_manager.unload = AsyncMock() + service = _mock_service(manager=mock_manager) + + with override_tts_service(service): + r1 = client.post("/dev/unload") + r2 = client.post("/dev/unload") + + assert r1.status_code == 200 + assert r2.status_code == 200 + assert mock_manager.unload.call_count == 2 + + +def test_unload_endpoint_503_when_manager_none(): + """Returns 503 when model_manager has not been initialised on the service.""" + service = _mock_service(manager=None) + + with override_tts_service(service): + response = client.post("/dev/unload") + + assert response.status_code == 503 + assert response.json()["detail"]["error"] == "Model manager not initialized" + + +def test_unload_endpoint_500_on_exception(): + """Returns 500 when manager.unload() raises unexpectedly.""" + mock_manager = AsyncMock() + mock_manager.unload = AsyncMock(side_effect=RuntimeError("GPU exploded")) + service = _mock_service(manager=mock_manager) + + with override_tts_service(service): + response = client.post("/dev/unload") + + assert response.status_code == 500 + assert "GPU exploded" in response.json()["detail"]["error"] From 6b58b53068f1927dd60c004e6ef5267452b1cd90 Mon Sep 17 00:00:00 2001 From: remsky Date: Wed, 3 Jun 2026 23:32:15 -0600 Subject: [PATCH 36/45] feat(benchmarks): add model unload benchmarking script and update README with VRAM reclaim details --- .gitignore | 4 + CHANGELOG.md | 3 + README.md | 32 ++ assets/gpu_model_unload_longform.png | Bin 0 -> 259181 bytes assets/gpu_model_unload_short.png | Bin 0 -> 252231 bytes .../benchmarks/benchmark_model_unload.py | 311 ++++++++++++++++++ examples/pyproject.toml | 1 + 7 files changed, 351 insertions(+) create mode 100644 assets/gpu_model_unload_longform.png create mode 100644 assets/gpu_model_unload_short.png create mode 100644 examples/assorted_checks/benchmarks/benchmark_model_unload.py diff --git a/.gitignore b/.gitignore index eb94894f..d772eec7 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,10 @@ examples/assorted_checks/test_transcription/output_long_form/*.transcript.txt examples/assorted_checks/test_transcription/output_long_form/*.wav examples/assorted_checks/test_transcription/output_long_form/*.synth_meta.json examples/assorted_checks/test_transcription/output_multilingual/*.wav +examples/assorted_checks/benchmarks/output_data/model_unload_stats.txt +examples/assorted_checks/benchmarks/output_data/model_unload_results.json +examples/assorted_checks/benchmarks/output_plots/model_unload_longform.png +examples/assorted_checks/benchmarks/output_plots/model_unload_short.png uv.lock !docker/test-client/uv.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index a8ed72b7..4e2cdfc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ Notable changes to this project will be documented in this file. Per-PR attribution and contributor credits are published automatically on the corresponding GitHub release page; this file is the curated, human-readable summary. ## [Unreleased] +### Added +- `POST /dev/unload` release model from VRAM without stopping container; lazy reload on next request. For freeing a shared GPU while idle. Reclaim scale with load (~0.7 GB; ~1.6 GB via long-form test on 4060Ti). (#474) + ### Fixed - Web UI long-playback bugfix around the 10-minute mark; in-browser audio buffer is now bounded ahead of `currentTime` with trailing eviction behind it, so long generations stop overflowing the SourceBuffer. - Web UI stays responsive on extended sessions; waveform animation is transition-gated and `PlayerState` short-circuits no-op updates, so controls don't drift into lag after 10+ minutes of playback. diff --git a/README.md b/README.md index 5d5f9161..f3e01790 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ Configuration via environment variables, see `core/config.py`. The `:latest` and # The Docker GPU image is CUDA-only and won't run on Apple Silicon. With Docker, use `docker/cpu`. # For native MPS (Apple GPU) acceleration, run directly via UV with `./start-gpu_mac.sh`. + cd ../.. # back to repo root for the paths below + # Models will auto-download, but if needed you can manually download: python docker/scripts/download_model.py --output api/src/models/v1_0 @@ -386,6 +388,22 @@ Key Performance Metrics: - Realtime Speed: Ranges between 35x-100x (generation time to output audio length) - Average Processing Rate: 137.67 tokens/second (cl100k_base) +### Model Unload / VRAM Reclaim + +`POST /dev/unload` frees the model from VRAM and reloads lazily on the next request. Reclaim scales with load (the activation pool, not just weights) but plateaus: chunks cap at 450 tokens. Long-form = ~30 paragraphs. Same setup as above. + +

+ Short workload + Long-form workload +

+ +| Workload | Loaded | Floor | Reclaimed | Reload | +| --- | --- | --- | --- | --- | +| Short (6s audio) | 3.11 GB | 2.37 GB | 758 MiB | +4.9s | +| Long-form (7.5m) | 3.98 GB | 2.37 GB | 1,656 MiB | +5.1s | + +Floor is host + CUDA context. Reproduce with `uv run --extra benchmarks assorted_checks/benchmarks/benchmark_model_unload.py` from `examples/`. + ### Transcription roundtrip (WER/CER) End-to-end roundtrip: synthesize with Kokoro, transcribe the result back with [`faster-whisper`](https://github.com/SYSTRAN/faster-whisper), compare to the source text. Scripts and data live under `examples/assorted_checks/test_transcription/`. @@ -548,6 +566,19 @@ except Exception as e: See `examples/phoneme_examples/generate_phonemes.py` for a sample script.
+
+Inline Control Tokens + +Two tokens can be embedded in the `input` text and are parsed server-side (API, WebUI, or any client): + +- **Pause**: `[pause:1.5s]` inserts that much silence. Must be exactly this form (colon, trailing `s`, case-insensitive). `[pause=1.5]`, `[PAUSE 1.0]`, and SSML `` are not recognized and get read aloud. +- **Pronunciation**: `[Worcester](/wˈʊstər/)` speaks the IPA between the slashes instead of the word. English only; use `/dev/phonemize` to find the IPA. + +```text +The city of [Worcester](/wˈʊstər/) is easy. [pause:1s] See? +``` +
+
Debug Endpoints @@ -556,6 +587,7 @@ Monitor system state and resource usage with these endpoints: - `/debug/threads` - Get thread information and stack traces - `/debug/storage` - Monitor temp file and output directory usage - `/debug/system` - Get system information (CPU, memory, GPU) +- `POST /dev/unload` - Release model from VRAM; reloads lazily on next request Useful for debugging resource exhaustion or performance issues.
diff --git a/assets/gpu_model_unload_longform.png b/assets/gpu_model_unload_longform.png new file mode 100644 index 0000000000000000000000000000000000000000..9fb4c673dbb27502d8e0933d0e2862dd1b966b5c GIT binary patch literal 259181 zcmb5WcR1VY|3BWPPO0iSR$E2KDMeAWcemOz6hUhfnu@(!J*uTf&Dwh8u8#H=*W9fUF81(O zV#1<#g=OwsvvYNQ?IJ58;`IOhhAzaUod*DYp_ZKZ!U_=sb5M44jGBy^JIYj5~ z!FS{AHLkW}uZYlqcP~i^SdaP6UkjV^l!U5Jf&&&JY-(jox(H!G{|Do>4bN}lF`2XGEpBh*HdHM{_gGPK!gC=l;wFYDR9HyCe zk>CNsyXx&hznQ`^%{!iZ{)g@n8IR2GEGhQ99yfRC8mrBwKJr=~fCW{pROOEhbNU2V@4nFU7eV;OsHA{WGCo>!AH{Jb+BIN1iCzf6@>brTX zWW+(R!!-e3f2e3+=9TqD+S%d(!L=w`I01&1vDh9k49|%-yAfyRz5c~Ad^l4hNwNp$ zhO+C)+*8MMDeSjANBkUsIhxyh%6g>UO!cyPk+U!zCN+LJn9Cg+wo2P=_ci`wi`_0^ zl7d$Db~_^)=nUDf%D%_l2nwT&x&%LW>40RauzY9d*ZLkD@qzPI)lGMTQ?)AJpEb+K zr0EW+iJ}re+J?_pQEd2o1o zJJ_B1;!rEBO@aEZ+n4<0t+m}9eZpbN#h2=76Gqlc6$p&o+gnJp+?eGTweQU?4C9nR zxcY7ntnIFpiA<+>j7J!n`w1!@tVL(muxpzcJkRhcEO}ZCB|`o8wne>N?WvDUt2=p; zoGM>AUcaNx_*PA)MPxoon zw+1Q}*K9oX-R!8;5v~|>4_)lF?i47Jdp-GR{fNrL=9hMxrz24!%raaiyLayCr(Iz* zc0TUPFkk+;f~a-p7A|grQ)SR;&6L;UUh%dIu=Y410jN!!k6fim__XM0DVX%%96_5Vd9F7HTMQbR7_JkYcTU! zW!>yj(t0}(&NtAi_@E$1k?e*~;Zg9}dHuCOESFNhGh`W@AY9q2+Wussj^A<2hmwg(xQ0uxI@6Iax!NRBc z2~jc|725o}@(RzR_oF;{sov`B+Gr*}>P`V-QF_>OW;@es(?$n9pX!5N9P=s4gnN8@ zGaV4F9X?VC=M30ue-$e%<KCZ!9> zLpwYDQvVd=`#pt~<5z}X%v#v@=j*JL>m6r0F`u{%7h@Dx*l5*zQe66+qY!ITTt%+q zv`F)FFIY7_88W#;pr1EZg!B?ey~3lor~`)PGO})oMcK#i6Qds1%aMjeQaz_TS0*5R z6(bJO4n-4aQ$o}kpHTa6Z=6DKwXud~bq(t1&*&K^LPdcqR{y0-<6q|_O0~2d1Pi-s zXIbp-7o6vmlqc06e^!k*dGXfEGRaADGZn6hCT;w8M9kC|iD6i#9%k>9oaU}FrXPy=<~?;gVtPdy=k^_TqQ z&{Dua0UN<^389y({t<=7l|0=!=g|0;p0spdFV7V56GJEDG5OZ#abK>6YMm18$|Y3( zX-l_}s}%(KL@BsIj1$|vyBsI-d1z;Lu{US)d|HyUv#$KnZlvAf&bZIQkUv!j<|FmS z;gX{7WBE<>x=B);$t!4-Wh#jvIrJh03Kn4DP!#OH+a91Uhq-hq@UxryPF?Y;wV->C zH-^P74wXELDYF>M>yp4AAR4O@3oHhJ)m^=d>k$fA6PvlX@m+F!M>Mae*Rp z^H!T6yeU+q&Ugv2*(vAYSUJXiK@nWNcJaSx<4LjbKqWlQ0nP`JHFN1Odos*-BK=^S zRBI5_l@mF>yUya^8qhXS(!$N2BHLj=s6MIxeYKML7v`W>O~kBUpsc>=-bAhjF^c5l zb)GJ z>}?-%t(v$)f9uIJv@OK+?-p8gAGRhqz%9Y9%yhQod3oyoBhj+S^+(JKI$2a*(#c$X zncwrPbs>E<(fl!)%j@$=@EF)$<+!)hxF6+vzMA*eb6Ljc@x2U!cQ_gE+=UNAw1i_> z4aj(SpMz7DeIIYh;f~3ZN09fvN}Te$>sKc75OELf%YNNcb0VMBe!^dK&#pTQJIL?OHd^6CXc~bP zC&E$)@OqKuOs9(Bnpw6tp?*qWV;|^r_C9_&P_mTkm#LRGoLQ$9D?)$%wxdOb3B0y) zmU>#E0MuV>T>VmRwQ7ZkHrLFLum~#2CqLbqGGs|l&k3$DU+N!N^`?~b!)X06Zx1;2 znIz^D+I55G_0tY5#q~$-e>c1MQAGQgC}ni)cx!RQt$A_WZ*|B4H3kZ{jEEw2rCq=& zU}sp)ra?TNgge}Mi;XjX-LQw0!1nPtmG^k}WIovpj8BtwbD(X09R_+H-(Tkmzn-ug z$$0Oqu*i{X0-(;#VWQq!5s;CiMA(kNuoY7Qg3Oz=CA{z6Uc0=W8R{MeF*Ge{Ba(4M z_nwQi5=dwGy>3;q!E!l%XLXYu629V)W&hE_MX1OI{4+JD%u zxwV9IA?rb^cOY_xJZq5z5?N0^u_A zG0$N|LeoJUW=tolWGi zd1u8~#a9OcymtFlEZ@zyh=CffGHAP2!aa-?_0)ib>td)VVm3c8ZpU*lxsM*-&TDYY-AZ#oD3T)*VK6S z7Pn<=igOJIl{&JvMK`GH91HJ{FQPNksN%YCEycY%@0qB%Pj`YFvv=-dB9=s3u9hhS zWOR&wD%o}9zciX8Zm;|4>YkcJFJOBu&bx=D_e zdtCJxwI9X}h%O|j*o=_avmUzeXHRaTtGQVtl>7}1_7o!MIFgmdJ!ev6^%6YCy%xe_ z4UC0TeMob+4QqXhFPq|iB~%|yI90yaS*`M;R+MzgqBzP|PBZsU)Lppm7rAf7taMW5<`ud{|$1T{%z1dX-!%B_(0HwsT}DrkUi2}OVu^PG+4(O$j1r4O`$?_|dV@loL)UZH9<;eStJcu< zAxkY=^3wi|pg!+yB#N--C7A(lt|!XDoki zf&N3oDTT`x@xm@oh=XM|4;Fi}TC5s6SU-4g_2iXqqOLRP0t^TdEc_7H7)h?#e9-T) zknCbansjzZQoT;6k2isDKMy#R$g=I?7M<{Q;V2)+n@4;ADEI@YbhqUqh$aKLW_A;4 z189lzow8ql7+sXxrpmh79m67}FcAf^#1Fqu*c{e*OdMV_^AW;)7O^6)>jGAxQlV9# zBwHJ%jz`yI{Q5KKr`|xvLaNWLv&{Z?%zW1|`9ERsG(}<)z>;4MIRTOqO48?D?PFH@ z)W#d_wUmRmT$^kmLXsjIYrFzmmi+1gV{4Aqx==Jo%)PIk3bVu6UJ_sIxlMmlIlNP# znFrCTh#gdmj*a1cjx%m(&pT3%;BII2UD3n=0CZ{AKy+Ohx(@camiQTf zGJo=Z=YQ;%V3h+cjvW{PEn)cZ<$k3QZt`zcf`H!mx{IXnK5`f-w;xniXIaelS!&_tj2fE=00it7RVr8I2E#e- zymMx~wWhN3v@+g@=`7SP3#*-M%!*LFJNP^ce?AQksM5}}j5;RC=X^)& z5$|T!TY{UG^31g*-?oXr@QF}xZ+$rKmGdCnzIIFcV6EQj-PJBx;&hb0T$X#zGQLYe zDt~gWOM0eJdh=JPKc!my%{GmnHM)y2{FU9%y7c)Wmj9C5?iAH}Di6@WtdW!HBT;Ab zDdc+lEn=nppqW1VQ}u|YmTOGzX~JB=tf@HA(Jf|Mb)F1Yvd*fkI$e=Yc_`4#(3$IvF1??TNpTr6VjP!oq2s(dfi# zSy$bsP+^<^x|+0e_V@dm55hyT-K(!Ziw?+EQR($5)aGB)RbRR~>M>zjCi&9a^A>sE z#0lfP^X&R;yKuEIfO0<^|A@p11fe}fO9C7#jM7$Q^@e9pEqy*!rY3~hEk2pdub11z zC{lApYTVQI{8i~HyJ)Tz>_YhPUw6Pe=%Y7tOI;j-*y1|40VhHmnYa#H<6FRD7?jpq zCSG00&$`fhBvcS~l-2(#x8-27i-m};etOlp>1e%?vA8M0)(mP}TwA|AXhOEece9QN z^s}2dK3@%>0`&Ib4!0)mX#nfhh!yZxhexb*eNq zTiXCVPaQxbg?g%~cQLo`+W7Ea%wkc2u}hyxLcNb=X;eni=-YZrnpsqrUauQsA*C|> zaECK8$l5mBYct&FuJ^EYd^o^$cNn=QT>DansjmI`JOPy}SrOxAYp}eZ)<6cbeBYIG z0*+HOG|V96tFBckrsdgN)VZzrPlx4Mg6+9g_dly8P%HG+7)_x^dlWNfO7yr`HO@&_3XVJ#l_cxs? znLYunq4Lj@vs+&F`_&xx3LY;P|09~Yna+%i|IMh=NvdqO1j>l$Aj7+wr*GS`O7B`? z+D7t?lLRuMe%sjf)|-{KkOY*T^}wu(9>T0!6_2r(kWBWyMs#3PKRB2anQ)f{No(oRU`Yws>qp0 zUSZFvAo}UWEM}yx-Fk%XR~e=aVS`1_r@c*#RFd4aRNoEKkW=MqXx$-Z(D)W%2SD0; z-@%pXek09BAAiQYEoe0MiyMcFq1Y3UXvs4L0h`0NV(1%&pDiG)n)v;j;k#<`+x@jA zR1$$>?l9@p34Y(`eT{-;BhzI0g_W{nHZ3JTIQzGvJL&ht>=AFp3%+3&Qdu7bkMjUz zZ#~FYfM&g@^)gm>$5z+Gb^4?753v3^QS~$a)YZh4n+39Fpk*MNQqZ*=(AN%7J-=Ir%LZv8~tpm-L)( zeOjPbkilI|C-X%8+cZ*_}4A4N5X4&Mhe>AUF&;Mt8Xn0 zF|(hjbLuZYq)Z5r>Zr@386O@VJMqt^o@!--_-0H!o2mjFR9J3e^Gx{hZOCrT!A2R9 z?F^D%5IuC0(L1N@LGuO2oD)#*sO=BuIIgZ;MVz0$>1jVTbpI~H6b}j z?bGyTJTNp?wcn6GxhLaM^<~9)WT^sRO+DH)u`^0o>E@E`7j!T^SGl1I@@?+w#Ov=* z*75ZuHn%4X&EOex8Qt2oz|i-(k>r(=-A78C@ur5>8RNJckR-LKNLF;jzGRruL!RIoJ z{Or@iw&o$YTt#z1y>FBTdU)rU!I8oKRbCCW^KwDU??`M+Y z{9}Fi&3I-BYRw#OUXzt%y1X%f6n?nfA>`4Xbw;cM&t zomD;UwH=_1d`j#^4ip;M74n|l$XUCOG}B9Yo2?ScE>z0!y5N+VXm-OEfXMb?K@oi? zzL#~%UPpZ~Ubd_Q5O$_ht~lgYJhVK!_|cwf!*Mmaj+dsYXF7@iR2va+^^ZBnc+-k- zK)COKUPFqa#{tG1IOIc4$r4sM`A+;zHLkawS~YV)M3t^kJdzqBc*F@SHdPYj-6okj zdy-08Qac4sO0fzVi+5oTz*U$O;Q=+YE>%&CGe$}~P5wf!2J;-nOD|>DKao*m^XQ`< z$LJk>-ae^jzc*LilKw#I2NE@y;?TXcoBX=*bIJD$CJ>k${~NcSCwbO>#}?06uI6;B znqJ^x=25)y9=Hm4S@4*2V$DV1Ur3dVok zbgbGy%nA9(C0iabf|!eyg5Z83+T8?iLoq~jY=H0@fP~hu8AKcgZ&oyEo#t0T(w9e* z@ojVQWOk2HXX_qPEDeoFUWd9pYFRS7M#qm5980?DHG6V+)_dE?Pf05};F!9&I8hJp zk8{1i<@(#uSfwwk?NIR9d)mfm8~P15trZouWTWp z2FW5-&D5#J6Y=n9ReCDZrLU$r1h3=62q4^~MI=V<06AJX3^Rzt3PihfNZ3lYVq{OI zyL9e(NUl|X(Y-AOu}-y)e9V&3XF=6Tbwi9r)*?s5r^Z4v*J)Lrt;*EVkBH502TN<7)KR5MV zU)n}w{XEcoXaj}D2-AAm_-bu-Oid7IOvueiThme06M#UMW3Vh;nfwSF-go%3-; zH)sp_+;=^w?5Avq0>8;fUQIM#Kil@9dZJ^9CaIx zfvk6SYz-?TQ%B3~*A-8DZ4Xp3m{mNFen_(&``Q1M_-5H6b$?4Yuh$Utty7M$&Qy+Q z6V!&f4Rq+}y~K)ZmkdEf89tvngE#f?k8OP#}!Dp{dSAH zl~XgQVnr#MXggD^u{sQNS+zf;k8e4e_(LM7lnL>poyvC(bC|ws(<$yq@5oBYt8V>o zY2|s8_;;Q4nqPlX;3}VWPPDBy)7^~!uky*jO~8SEM(0_vMR10=lCo8&ylQr*VG!@4 za4tEk>m967`SvtCL6gvLOXa1wI~LaaH(%e?Re9Sej#axJ`>_2<;%`&t$Few8VlEP1 zaKo4XPQ`QlrMu}H4#>IXIx2T(V}aMv1x}}!`ic6vxIKYe^}tkF@08mW9qq32=ZXP`FS<5k~1t|$9Z6j*88sey>Msg`k`&aT>3`nGZL2x@yfF7W5XY^x1rl zH`4;1`sq(}^=(~#nmCG*T&<(-JeimW=qO5~^s^$zoeI{;33q}}NJZ`Eo65~=Q#Tom z-4m?i?u-!sUedGwE~#`(;&Rzx|-hNY{E^reZn=3g@?R|BA1-i>D4RM4!yct6!XUe&fZ=>GrE-Q8RdyQ2IxTi{fUQx7 z?uDhe4W2XV##S5<&6S_T3#Vj3vsJVzhI8ln6cdE$LI42mkD?1zeMy5r0|uxX#1b51 z*KcJ`7ncM}8Y-;J&hK*-pIhCrlXeD$lbI04UL zkBK7A21|U~^6-myZU`+0*X{>CHWcTasPVFGjYra26axHy%CZ={1F!>s09h*$QYbvo z!K@6}KiTIe=B9cFP;+i%ybnoa^@HKLWdnAN5O*MfbPq?C2wI zjl@}Mt1RR@un4G@-Ra8Hfa`_x_+Ls`J#J(a`2u;P#+JT$zzMu$DzM|^%4QyXkLfG` z67JTRKcjh+BOTp-fVv!pNJZ^u3BXDk6%zlhM0t-y_QWIOW`-pSa`{ffRhH zBT1@jBv9CN{u50wG%zkuc27|m=-3=`Cv?ovYbX%{9ujo=P4y?9r|gy;f(HE?0o0xrBhEcQKYv6hh5y`vk5)O0TDSGz zv5R^5roc}H5~#;hrPGu3+;dr9#iX{9!v6diBuunr|W3+MuXP<-5;;i>&>AjF{d74WK#S>?x_TZ&5WkNnE+#t#@8 z3cZLnWN4ga`j-1`{TyHN?*K2Ev-eZ8XxJ$)8KExODiraKd7ST6?e@6iS3B!>y3}og z_=f7cQxVeByLUiaa=1zJk4uh^s^=8By?}yn7EAA%+YsnO_IplKS?j5X`8rB8*)>jq z=C)MH*RHb-CT0-5DZQ zC)X2H-nMRmF`a)6L$HBH?~_86aD2O7~+Csn71 z3=bFlCQ{u;=#1#PKeF&ao?9yh-Pm|`lGN|92P93GA_`E8nw=`gXtJrkSdHFP)0>}#ysMo z#GGr6I76X0bKV?#^R*4&I?SFYgaB1rT2p>KKM*CCtL^PX7ZKgB2w|5`h1X%rehSfy zEG54S+6n#KwR<+JW-KAr8A0r=1D&ze`>%@%RXssyN2F^cf>rUTsNYr&kczp)+ZOwW zFEbiu{C0-1yJwi#ASjbW;&=i#D zLpq=oG406<(c4>mdB=QYE2;7ep7WCzP|jJ^VotH>eH1{!{LaK8%E6N2pBh^`X~zd* zSEOD+*LHfXzBDrc+4egFru*S0$DG99JpbLlJl~1`Qu0GR0BpXg#}mhVItou&BY#WI zelQqx2Cr)!3YNp~TRCgobBoV+W@DAFe5waH$BON>KvZBYfH!_nmkuu@w~B{3%bE6f z#{fqW>zkt`b?(%}v;g7Y55lWz%6WQ2Ui;ThSB8#2>20=-Pcd41vx^+EjNo#i&#fG? zxNk7!YSa6zMW}L=_DtgfQPnvQJ_B=|l6F=@w;tGR&TIyaz?MR=8$0#iE}eA9=)E?# zRi{qG=hDPn&=nnlfDA4%qm~FalTHO&_OeL*!PB62`JExLl7_Q{A7dWo z(U2sxmadOU&;51Qr$vkpk`s(9K0cXAf46hcN7k$E&ecd-A!Aoc)#lJci2@_sh}IIw zNFFPzspk}(OOea&6kij_XF-+_{9lqE<||(<33x!z2%wI6OekKEnN8D4y8h$nkUDS7 zx2@1imE4XH*l3{VQLgu|ZW*>c7Q{E*<$sp6{}>3Ist?+kx%PG0@`Cox zAys+x`rMr}<399VYZQA#{MHe_YiT0uvMItI7AOrAo7Y*Uf{;p{4Zb-6VyBT70d?)Mr!yxKL*9F)9+h;_ax10ovUC=GXQ1|_ zMfzBovFL1~`o4U{P_K_wUdmC_wz-X~@9{=_mc?Q>6+t-}y(QDOLm($e(~PAr0}cHhl%Rex+|=U`mJ?9C#Ks4Z z;PmZSeYZU2h!8=uPP>b1W>>l$tO*h945$VWM#WWc6fwK*4&Q0jVV-oRlE`<}ZV+K^g>!deyd* zgUgsvdL;(gyQ5$EDu}CI-}#;vCTA8NAm^0}-qpZ&+&ccWgF=&;BOud|DVKNjhDy(j z2)#ZcL9dT@r5Dp{Eh@(+@&enq4}vh3O&HecP)9 zIYzd@xS~o@Qgz}a*uP?ZZ`$Bo-iyw|XPCUUfWgub{qw5NN2S0N%ZHX(;K_j;W?Jcyao9p3@OQ$IMP^r2-;MT*$rLRx!hCdM}R`kl0oVz zpP&DXRF>rCo73)HyhvmJJred&3vfp@SL&$dWt#=hdqvfwrC9~W%Ca41*|3yxdbKOw z!WH^#<@W%p>LmyM;Qs(^&AvhzqiFYY*gs5s=y&Kgb=W(iP1}+*SZ8=i_UI6iXPtJA zO+W*Em%0*5e-{LX1{@oMI&L;0oaUymYIH!-%o!kmne|EMfd*zSo;B3;U$Iy z&l~~DPEhM+!ap$z?f8Eav`;<@H~=eW#zC_l0~pMater0aI)bK(Off#7xMcAH-=+cZD>2M@w0&Wfo?T=V|Uw8^zelkiaG zu`TbI72{wjy83|`@;M=M_HSNg4A`fEpaqnW217RrIK{U;<(xxV#hyZg3-w34bxO}< zLRVEFsG3h~`>yEr1bHz&P(>_h zD{w+>VHISQ(ufC#MXbKh9>mGB_$4FSWUywI0D?M$Bpz#xF9TO`$&WT2!;w}%V_Vr$ zzkn#q)qsN~tXY;u(qPH{GR8xl#y1M1EJX>etY-wuGl{OW?1=AHk1e(l2l0c)B)R%| zQpT8FipoHF16mrp$t+_)Ub^lwwI8yi=(O44@lhws|IV_z6tH*E*Nkwf9gfqHpyB@s z6J)@E9IOld*HHekP>AG7{lpK}KzH23sBVtH4E_=qO%I~bLfneygns!S}tb>9NXKs zPo(WH!Rz*3^_U%JhN3_AWC^BySp~eNt9F%^H%_`(bdN>{$+Z-n9>V-XU+O9|W&=kn zNfH|WrsWa^i)#vct!b_}=hxcZX+{p#htC?`iQh-}#~C}6d~xa21o>GF8if@_yY=Wm<%1OSWIA zAn_IQjD9|4yq)8W1IX<4=VAtzV_|zBO;n%W@V!13c|Y&9R^6e1{K0buAV&*(4tJ&q z)(dB!=mj!nJBFDbVDOCCd}1K0WKn+aDo7OS!B73w9C>y;&jAgf#3SFmxvv6nP!p{) zAt)n|)&>Bx{p{w#Hb_Wxm2V953>d6cfgDE(t;-rOvM~Km1FK?>9ue5wz1bjolr%R zotT4KN~f;{npM`1gpGHBK+OUUN0R>Jr@P81qokM9<(i_!64r6A{Z8!rYs5ZkCCIJO z1^1~rhqLQFsxintr@!HQ6hk@s>O^QGGaEvku}}h_g268ILp(k!3ADU(rlQ@41$)Fw z$?-?q)SptK#wFQx{u$ABegKzXE7Q;^OD?SseRp3@4OE*{zKYcd*E#RSYimD%gy@*D zfVEDS6fcN1{Cg;3IYBU$jC< z!mGqSJImxP?mqMqb(LDNrGC24EBoz7FM(l=cx1!X*Wb8x9NX`dJ#J|C2M1*xNcZ|n zZsVReCr^9FL;B`5d{z$wb-7AST5Kvvrw2+u`ujs1NxO2rqFobL5`kBDGTYjSyy7O{CV-VBIg(3uncmTn@uag2T-QXsw{J#5dMNmA6Iu zI>;OLJRAB->b-8)iX)Vc?w|q_cuDz0Z;Bb}pN!a*v3P_8$V%^0>W`CxRCI5na5#@H zj#f`l?@(K^_8Wn$Rz@KD%4l3?Qt3;D(9CUh^yCAFVj8&>s2%+Fmf1Qwk+lvkMpib= zeH6S?(#SAYrZ^qQ*cp%cT-G%%3Ua~mm|A{TJSaUQt>t5Fv#Th7kn%B_o2%jWoGg)^ ziwR&A($-BP)oj*DFXx{YU~<%~1Sr~;ybeOJu=ic?@*Z4IsdXsyFoh%hLTa`LeT*=(HB-0*+CYQ=mnYQ(a?V>TzOv1g!I6O*59gnZ3{3Vq*tM+d z_IEOwD5c6$XD9U5| z*YdN|LALDjW*|a^t%yesvdd~yBYG$D(ex3V5)P~&q2L|V#SD0B7)cAe+?(TaFvCM{ zo3Ss5uDYJ9o?u+GDpa!;2&{maFr;D1(NwSw9eo6h=-Wxf6*QVc1od+L&AA~my!Kz`RzH$IVJg8h%|XVGG_Is-5rrdc0>t{|5(vZOr+<8b)b%P)&N;`;03oqO5DA#l$U`ki?PjZ7 zfeDCjb#CqL%_s3uX0Nef4r9Fzw~>_XHkC(mTJ0HQzg*Jpdb=KVxta$YrbOv;rygC_ z5Dlbh(szEk(db|L=>LS3-9S+72gm>#+tZaJIRBFGl6RRiKB}cC7H+`iOg%_g!SPD$ ze4-e>Vm#nDD?dcPKqg2${`n2_`#v`Fsnu%eIdBjLc9&5|`npiJUj;lMQ|9UXFw0;}<%6!D!ysGAm#GEQ7iB2UI0MVxncl?k4fknlzS{nGJ05 zqrSS>!V~aRJxOxaqh5<*g+h;rQDFEdV>qG9U^d3apm{U>T{yqD#BfnX8;87R>?g*Q zYy5N_z#5+mjMJj@ERp@T2c(ppu+t%&f=t{Ti2AfsZ)jGYxPC#JN#}AqrZk2UT;TnR zJ67X-FV+PpJ!-4U5I%8aTYcqKiSkYHF_F2VU#5;c{<|tFr*JAbzE&~k7uQ*Q4-86p z7x*bVwAgU^a|auwCGEVXH#~b+_o`}Y2ZYO+fZOd3Qysr=f_!WM0Sm1`($;(u`?l}e z5Bc}Z$CCJsfWnI=>RfUa5eNy8@f_F@*0v~Eb~GzM!cD<~r>KqyLn<}yWOrz+f-xCW zZoZVQN@GSYj(gJB(vOlM!*LgQ%(h`#STIVl%8=#*C>@qQLh1}HT0827AHbs6j|~7i z$b`Br>u-|DAjn$#W2BW;@~4j(M`^%VwFp~;qPNdVNlQ594_RT?z_3Keo1tTdMH&SR zuTsxx>AGzV7*Jg$n? zFe9g@9K0_g7%1~a_jDl;qsq*~c24ojO8&n3169rtbC0JEf`96LusF-s)&tfZ$~XYs zfX@IuJVdvkjrz5Mem4%5WDvV8NT_F(t(yrKq=k1Q5QfpKug8%NbNL=*2|-^Hpi0F( zeg$eY2#2oSIaut@@T;w!pLZ&Dn2|LflSGVGO;>#8KjQ_c``^aN=$rxt**9^=-5evC z6QDL8@*ebzo;3eTkd&7E;Ls948_JjoWYpvz*`c{(qYb0-FHx-GU4329S6J4+Qbim# z&2Efs8Y&|lzz7^g*)KW|XA72>xR1G4ENZzy4j9+AwgzsV*@ho_d0p5LJT^aonX@Y9 zmf&-TD+TCL^86BT5z>m6#lTa$FZ269N^Hohp|5Zx8@DPO7ocfV`6{64w_^^PK1MrO z-9=ba@r6cxP(p%q$@1xOWEttxY(bqtUz^`WPdAN3aT)xEkg>kfSA*D-l`MSogf3lX zyA(;G-uC+WD~YO^mNdbr#g9#=_I^rO18dQ>Y3A1hJ5yAP=`XQ zNmp1gQIIU?#)CiZ$rj_s_wsJH~hhNlX@Q zW=imDaqs*cn@(Q?t^^8eYxXm>CE3M2 zWPjwJ$E>j4)J=={2gcK)ptRA0`lIB^BG37Rg|c($Z8^oV02CG?K-Tm~2+a5pcDMRO z5O7*-@e^<-@{FmaAVj`^mJH~SSZFphuZ<&qt|XVu#c?V-slHqS+ITu}gCnUB!?CN| zkPLE`>tPZwDs<4x8<_LiwvPWLq&L)=hAvzGlF{ zCIz#6S})Yp2tFzeQF0Kw9jRRHKEM-`+<4bZu-YJJnN?yU7h-*wusUIWR0<>EwO(e6 zbynTo-zF%4hFA;cL2@P_ng^>lA(=wCiH^acqUY{o&qnA~6=q<*pFVr_3mxO8?EIHU zi24Pn3(8QT25f1RL9_i$+oU2(TI2$2ehdaBK7xSR?2uJ7mn8snz;uIVG)iuO!x1v# zcoWq(Q78yW-&FTRP{#HIE&_sj92^Gn_ur!oo}*Qk>JaTh9$`Sk&h9E9QB zt*1h=@4I!SDmb|D&>RqfqIll%+wLZMnso;o#K8DfFba3u&~4k;D1I*GuRK4hJH#It z`I{Q~+UP4IUdB9v&G;u?BP98MwYQu9jAX|A8ZcZawr~Z*2rV>QS3-yk>y$7Yp#KdF zY$apq1;Q_jxr4;!@<%UM5_Frvs~L^XTUK8E>9f+8-nM*}8(0)|(Dr~$jVZRH-7-L` z(b=oIZiJ=~0co0S0fuv%5l+Np7vV4o{HT1V{AQ+OZz#B9_uPR}pgI51yfx;b)c@SX zxN|C|tsFif>AeH3Xk6elUi0*BZUH#}d5@&z&I};v-@?nHvGr0GO%#d2r@3EWm)U59 zBA7<4qPJa~1h=nKMu^=*zMChV2Ov_-pl5v!8t;3o#Ae_7=R{qc9;Z>fzypru3c^Ov zTfC7i=8irF(C=o8P`3^@^k~5GO)!77?U&|vp=+d5p60+kcN<_sZ6(@oq?G$(m+{5! zk9uhHLm)Rtlwg037OXo!Jg;3O3=F`mO(lJIrLZWpfm_s9Akj-NpI+dfz8#GoE2D^wDfBPa-$YA?Hq8FY^($N!4uOZE8VZa80YDOLM z3n-fw8A0nozAXnp+YieM-#65uWMu5qJe}e`Fb=r>gOjYkC$X8F2N_dFnnCy= z9kB5lBXv;hDK8yI%2h}&_6*HCJdX{Ga%|D7qwa)S5NKPNGtRSYxd1XqD3SSdfov!y z5qeq(IVginnK-_UG9J4a#;w3h8~mx->J@V-s$58wv+>J)$an3hi4g+inG0e+zG$nQ zFx;k?jla-BUWE2cxD7xyF`xgoNhbW5@eXv4ovgNNK*e1L*$g({3l)yTHzY^wYM)zu z_B}*{7;_QJz$0@oeNeT(Y(kSj+cD!h%3PO#IcG`26I}ZgjRs~BG^wZz*7}Q=?Z_V2h4|(is1Vwy==DKda#WPxUW%uLEN;6^KX~KRC>NF$PCBXu}z3 zAQRR3#G1qQ<`{Xtq|n@bHbsyPOq(4UENvI&f_z5i=4e}pfjOR7dx8$h?k?W{0bxtwT zhQ<5c-HMqNcct+greMHGGsOng3xjOK^)CR{UItjZ@7F1a+)`{(hk?9 zD92HAU;>|m=~cU#+*>ZIIjmz{E^yer3$HKE+z}p^%iuJ-wBq@nD`o zb#n6)>!0+UrN-NVjkOo2SVJ34-}u&|BJZBhDK@Cz(fIz-{%08Wted&T=mwXfIx1c#t!rxt;TEAIyORC35OLQGai{S7xHhx$ZdX zu5_6pEm$p9a0Y_G%7oRUk6Fi^` zu*5sO8VH?yP!1)5J3pK+sGENTCPzDJ$IXv%hUmEk5W{>9Y#$wxgWT1Wz$l>V`82R- zjrnel=}gq_7*AadpR%eUu`%@)bhD58LM*=@@o^_Mv|ZWK$+Sye9!Yrj6SR#SQe^PC819 zBIrPYm9?N`Skh+UbeF*JaV+m^{x~2-|6)6@M*BF2(c(UkeJng>Hs%|g0z-6UY17I02_p?Z4?=ITv6t7yN|{{dyoN!z`iyvC>WqFDxWg#e9;-3X0qSRs7r-FU&r^SSoxh^* zY+|BwYc_;7Jq3;J*Zoq;N)rvC?IK`mZCD~(yWqyKaJ3_g8-zR#>nIme#lN_5M4eDW z@Vq~Hxjr|xN&N8iKo152vjK_kfzAHBYAID$` zVQ7|c3^y=j8zJSm;{+h%FjdnUgF!d@Q)p1hK_VIC-fbToBE=I3fcjkxy;G{)&)2Ev zpc}!h#!=%q{xOHPtZiZc<^vG?k!}DX+Qdc*c*lIdHVfLY5Prixa)r(CpfWw=O~LZ$ zTzncEZT_o=!VlqZH%XbZkoIO68{X+Y&E)5n_l$8vlGK0@AU8 zsPraHlqy|%4M80&h@c`}dhZf?jYyMTq_-eFR4E}q2)XCMDZcObuJzvc-uvf|wPu`& zB+rx2^C{=-v-dvc6k>P!kD`iaNCM7j%6TK8z|D#F!d%g$0?kEtP@NkeUf7qnRNqkR z*sXT}fs9i*XyOX`tzQ=syBbDbotv-k3q^Lb}isA6P$hhyKnk%M2_t5B2axtPs4jo#E za_U=OMA!q@L0j5#sMHe3aW{%mUw79R^AJ0&%h%_1XD&n3$y2_za%JQtPOYc*c4yu~ z;iyj!SZKtR+eWGcv`eVOu(@Br|7MI#hVjIucR&4%IzMn7XLBAP7-LwzMU&4(u`q4# zG>(Yec{g40NrfxleJ@Ch#Ywj;H8{UkRalq3P&8&xv}y`6t5MiAk{ycRAqvooKW<6y zQ;&RNhln*~S!}NzQP_+Fi<25pGTlpLXJxL^z74UCqh*;R{t*-pDjt3~bZE#VylJHU zPS$Hc?am^ouGs?-b)uF@XtiPW;o#SfU0NAN>K|u(_zY`mpagdm@!9BhGdr-(;Xe>b zGo+f(4(HQ3&$Wj}cO%6`(mcfQ9?MnQ78&m;=u|sj)nqR3E1Z4+Ym26t2g;gfshn8- zpxPYz@IWEd4tk?kODCIGNaRM3#cIu>`~n1FnNDkYT}dy-^PG-Zva)r4$DY}P0C-)O z>&NoqSdREhNuIXQp$)vY14>S=A066z!Fi;h`4j7%g@hH-d84PV?edT?;@(X`@2P3~ z>f%%liSmiCWjKePmco~p@5*es>*gQ7DyS%ZQJu$QI>mhiIefV?k1VmtKWsx>hl-u` z`MY%O1E+fJd!J@43UcPzaF*tRF5z&g0%*mtt<7dhdIw;WU*7*rF$HJaE{AFG(_=$C zIiOtPnb-i5^iTI5SA3~_3)5Y-ivdW@`%La#6jARLha}QOR79E&DfBG1VA#Tlj7w^@ zY?r5MpcH~Jo||D$kUIyO0}Km#EOPtk2AX?SIhp>e z9&s(y6riWK!t&|Z`C{*aGl7KO99cNM)QdK*BmNK`8-%0EI_*Zn1WG+Pq!>Kf1VU`D zk+Ju0Bg+a;HB_fZ^01P(7)iiF&F`J$_r4vqht;9=es3NXkskoB@KlZO@uT3X5j0qN zlxcU?>yJqt@t`;_ANH(=j7w{Y@@rt{g}=391W3_E(L282=)NbUN^&YKLL_Be5h)jN z7Eo!2`Y%G9SCkJ!>0A!kdfUg#x7=o1gfcerDLe&y>UID5vxD`JT5s5KyxW4Jo?2{8;AaxY_*773{`#< ziMEGy5OMR6B4>PlJr}nc=Z!UBGSy@h42gX+2E|=3Qogdc0?mPYb!!Z%+PDD@;zmAIt~Q)42Y^2-N4ol>ee*lk$zql( zk(tg5c_tgA%$CD5sR+;12bPwY=9SZB!r#c^d4xc@t0Y9htErS@)i2a562=zgvE;fc zc?a$9K#ADeAV_qVqe~#wqM7M%4d;4njf8Tz7^M`ccN-olVUMRt==E_;*z*2N=XY>? zDgo7f3nvfLvw4guO$~+%WzK#u)cpHSqCvUdd*h^;zN&X!80|^&2YZFA5_)2Y%6>7ApuaCPs!w~os>VWxN~US zd)OPR(UMN*IH#?}9^YaD%)I)6?j}NQc?YA<2M&myP7dOd_R{TpceYuM5wXxibr}D| zjZip5aZm*h@tyjCW^S6d%k(N)UmJLFYzW&s^J-AIbzjNt!}udQ=@zTW@`y4WC?=`u zv}Q>=S~QJ#?LK*@BK%Nu zoz5#unX~d?EHBvHbQ5;j=tE4n25Kt9W)ZRRp>9IJ!H%Fq9gKYM z|8pL7CHYCq)cacduRkw+o441otbQEG>!1*IjlVRO-#;%=IoW>tlxwQ}xs))EY&w=T zC5@t7CE%ZA58H+o0l#e@Fdw~K!_IS~k0|?1_vK;Bf$q$07$D!(()>o{RsgHIF@K8P zxOl?Z^s?1d8l25M5UT4)d*JazcQ6HS9!AbT)N}~AkzHbbsjmZcyB9q+xHv4Ak@xMG zCu-H7KBzqYq^9ECU$!cGLB@Y8+bFBTSMPC!dY)UkV(=HcJuE*8Liml%trl*V1zg*Y zF!(Yw1E37)Oq+TC9v4zz1qqODT|Tu16ZyVO26Pm6V!*NnU^!FWgIs!}Y;L!HxX3^? zx^3Wl94IZStxK#lTd|0?dv*Az@W5kP4Y~Y$1S9~&`Kvznt(HcRNwYlLBXu8(mSe8~3$j*pONZf~MZRSu-bK!;Z5|@)fec}tR2_WhA0Y>h)wkcv{2lQj1%1KC^PIu?4 z1a2TU7d~^5?rdOA1cn1TN?!xR0(KHO2ee*FqRpM{UT!#Kr1Q25S^+NQ52tRB5xJJl zUAsfLS@m{CYrZHtlMErq-Og`CQGXhm~<^&*{i#>BVLXXVL0DkGF7JfmyWHRyr6;9+$^F@F>3S zHWD!0cDcBogCts@%K_rtOAdvXPj>AQ+oalmJy{F#U`f5vTEqhn@&;VeonfKJz`*YQm6Dp12e%xJ4>LazEbR(``CKqp!B+p`af zA)>R(a{f_4-Xu7K+~5_2mT_H>r{NSlw_MA&l!jU_Cj@SYC`yXtMjMk?QVLttc&6K7 z>LQ7f0n}@o{I2pPyo$^$-~2ScV7 z7x2=taA@7s(3xY)DqGbEEC4b)hI_+n08mw_ecoFuss!$~ayZ1GR$UU9-p3{&7P7%m_t>D0prfkh&Ip`}co4>Q0M*?$ zR#Qxo@is7nDnm*q5Dq-xgvovVash`AlSOsHVK?>}c(MuFNx>jJCjy>g{Y~Co_Vtmb zeM2(lckc8Sd@Y%i7b38NXoa(P>n&bhcu>974ygl!nNLDGgRNe%M%OulK+29?^o?gv zay^IR^8zr#8!y}lmE}BlmJ^D#3D|lri8wH^fy`mK$vcmN>>T3?Kg)>^&OZ4HL}uP* z#HxY;dpGcz(7@vomzfObw7du~=&5q*pJPNS$I7eR-XW?eL@TJQ+Rg{X*Knq+s*o=s z-Wu_#z<4OcP$foS(WYiUmFm+<`I4{<$%wN$a*W|00uWGb$DYlLTU(@n zoGmrZvZea)?8O=}HP@%%P(~HB==K50!7EriFA#6Vqk<ASVG*rGnWkEz&kLLo}>N|y?W+m z+DAb>T3UUFw$5zuDpD))A%c5f?Gc^*$r>RS)NywwwTDt0s^9umBF;;Aj6PA?G}XMf zU_rqVTd$LPx)+?Sj!PmEi}B2f{sV)qlhNr>Z)KAda;kx3-;&o1OsVQH3+o`7i95$n z-R@LZ4XkpoQtTd3v(01CbL^Byu38OVg8Cooc6o9WiGGqB`q{lEVw&WQ;J&~IYFRtQgujiQP?Xf7KJ_re zJ}(Wp2@xt&Hl#tO_!hB+fwr7UY*80(GICEG9ITvr0?S{SBNKDm-HCv^ay{zZqBKak z=yqoY66K0{Zv}KxM%k09V4t@q;@U+DYnZ|*kRRpR&DcnprdoVVI%Kbs@kU5HDcTq` z&7yk{pSWblvAreAr6lkf2Z6uIp=C-n) z5RItH;mXSg)}Y4tvQtARW!UirJ(^EJY;H6CwycQSs+|8}&Dn#Xb-0<1+hS_VHhf`j zC7`|`SJAK{e5;eP-z3tulMvx7dE$tcQA`hHvDjtw*7{_M6V2iwyCOIbum>>m>gpyn zD#L3a50c_38tdzEn`y$ntzQ|IS4S*@06N;0<)N~l+gpz&vR;oXiz!vR(p1wd{0N74>q?ax&oyod|m}mA@F+vgE zwTsO%ySfI{UGLipZ96Bk6|Tmsg)TrA*1&fCMfoAdb6s!DUCq8c0{~7G=)zxS%lZMQ z3V}*^YHr2~nB5b4VqMR-1(E`Z>+Tg%uhKicg>lkLE;b zN`YNC2m^iC4s9Am6N7d+>2M(>c34S2!TX*k8DbU69