From f1eb1d959074fc3ee9e22408e044c9d7147d26a8 Mon Sep 17 00:00:00 2001 From: remsky Date: Sat, 4 Jan 2025 17:54:54 -0700 Subject: [PATCH] First streaming attempt --- .coverage | Bin 53248 -> 53248 bytes .gitignore | 1 + api/src/main.py | 8 + api/src/routers/openai_compatible.py | 72 +++- api/src/services/audio.py | 70 ++- .../services/text_processing/normalizer.py | 2 + api/src/services/tts_service.py | 93 +++- api/src/structures/schemas.py | 6 +- docker-compose.yml | 22 +- .../benchmarks/benchmark_first_token.py | 157 +++++++ .../benchmark_first_token_stream.py | 207 +++++++++ .../benchmarks/lib/shared_plotting.py | 128 ++++++ .../output_data/first_token_benchmark.json | 403 ++++++++++++++++++ .../first_token_benchmark_stream.json | 175 ++++++++ .../output_plots/first_token_latency.png | Bin 0 -> 252293 bytes .../first_token_latency_stream.png | Bin 0 -> 218628 bytes .../output_plots/first_token_timeline.png | Bin 0 -> 238643 bytes .../first_token_timeline_stream.png | Bin 0 -> 193571 bytes .../total_time_latency_stream.png | Bin 0 -> 248420 bytes examples/assorted_checks/validate_wav.py | 294 ++++++------- examples/audio_analysis.png | Bin 0 -> 5407007 bytes examples/output.wav | Bin 0 -> 3006044 bytes examples/output_audio_analysis.png | Bin 0 -> 144875 bytes examples/stream_tts_playback.py | 144 +++++++ 24 files changed, 1583 insertions(+), 199 deletions(-) create mode 100644 examples/assorted_checks/benchmarks/benchmark_first_token.py create mode 100644 examples/assorted_checks/benchmarks/benchmark_first_token_stream.py create mode 100644 examples/assorted_checks/benchmarks/output_data/first_token_benchmark.json create mode 100644 examples/assorted_checks/benchmarks/output_data/first_token_benchmark_stream.json create mode 100644 examples/assorted_checks/benchmarks/output_plots/first_token_latency.png create mode 100644 examples/assorted_checks/benchmarks/output_plots/first_token_latency_stream.png create mode 100644 examples/assorted_checks/benchmarks/output_plots/first_token_timeline.png create mode 100644 examples/assorted_checks/benchmarks/output_plots/first_token_timeline_stream.png create mode 100644 examples/assorted_checks/benchmarks/output_plots/total_time_latency_stream.png create mode 100644 examples/audio_analysis.png create mode 100644 examples/output.wav create mode 100644 examples/output_audio_analysis.png create mode 100644 examples/stream_tts_playback.py diff --git a/.coverage b/.coverage index 2133e27b389ef3109b8f1cb40cf474076dc247d4..3939b52dd0ebda88c588cf2eefaca81bdbb6bc78 100644 GIT binary patch delta 1557 zcmah}OKcle6n&F<^PhQd{E1_GEaW6rX$^In2Go$GC^Rv&c4HPuAw_9iCrnxkJ8^BN zC6&lWsAYu;eG*b7s)kKf5dBEmfS@7S5Ejr%2$o3^P$98_EI{HT;6CR=5~=De=H7Si zx%bS>J({T*acV}qg*!VUY97(g={ed>G5M67A|dND%e4%1(L85%7{3|Ujgv;b{-ge( zeoEh^eXm{91~o@rQfJistjB&_mG(tM^a^`Bxm(Q^i}B)kyTj;_z3i{UCJu{e5-NLG zYtj-ztf?o)iphu&WN#)DxK2c8cu^nD<=rwgb_bZ1N=UUBP5YqG$}Xf#>=)5E9Gcjj zl!|>KD)Sal%5fzxD7DulJTQ45`!%hZl}b5tqBsV%E}Pxz>8g2_vbl7-#qq&x*@Z%a ztg~&74prIDS!<#@S@C(ymU}EOFBV2$cJt8FX+l{7<<)Xy+9w%+Xd8%|_RD=bKu>N! zFr%^JzFNr?OQS<>xttq0(SLe$C_6Zo&z8nvXrJ~_e(~h!h+D{=bxWMDK9oN)S}J7o zYy4gX_|2Q2r+)kMWHPytTqTo%>4#-t*KKAS{0o%b(&^lgJNt1uC4g57h%TC6nCHoD zBS*s4yfti@#tq|lqrp5tO7uKUs0IDLeqGrau9fB96TDV`Tuej*ffsU$+ z*4#qZsq}xdkgDXuK&xl7N33TsSY%J^z1~g#*{4P5FZ-7D`r~mvZ^G3K9g(51Vpq~I9BXgfr z2O?+; ZhL9npa+1hq%yEa?eC25!95G-A2iCOA`ZBm+q z5@CIz=oeoq9v9JJ*1AvX)=dI}sF+VH8;G;ji9=gNSwTOHv9VEFtk12DjUZfj&hvl% z&pGEgKkoEAH$Bf?L7h!*=`2I8l2?e2Ap9lH;%Bsb+M<@$_N%|BR1K<{@`Lie@~ooB z-^s7ZK3R~iNoNZd^GT`VX*W&y8<5U1XJ~a`zjiv3I5iR*Pt}cO+gxOG%GNLjlD+G!#$7(?dhxJ80pU zEOgdOiQ)K*AT*ijVz3#J()nfHV;FX6cqBcZiKa8-u~aw}Jslg37{~cFIL<*W$Lsg) zmCIT~D%7i?URN`o%%owSaB?h`h{T7Y$Tzso2Qq1S_>N? zg#~6ghAiPG?Y@@L>eN;Im3A4wtiGf6kXK2UlvA;CQ#qli^6PS!a!y{BuFE3ffmv9h zqx}u6n=bUXu{vGtZ|7az2&VY8zYbp8&O5shkoqT6fR+h44ssKQpp|IZ|6{2Nns}QZ z&Dcs!r0fx_hq&xcZ#20}%+k!IcATq0fi=T54ya{*LfgZQ?mZ}b%y0k!GH>{)GNNUT zS|0*p-f(?&3B~^rQ+GxMm#gF|+d?tsgqpg`OZFka3gq4A~%e$?xQ6@;&*8ERY=hYZ)?1o+o|q&$W?S zQblwk;d^);-@;e%0&t4ssmFE1N`5_$7>4O&Dwy5x2xBg0^GkO+wkjvLDy=RsJ>kly zeeWI{T;I3$;2RICI6>2$p@%DN9jxqt#OVl~+h{s(x5K*Do?pH&H83^lxj#F3Bfr(C z*+6~N7W#8Jdi!<EhkB8nb|E4tMpdc6GQ`K^4<~Xe&P`^i OSZn|#K TTSService: return TTSService() # Initialize TTSService with default settings +async def stream_audio_chunks(tts_service: TTSService, request: OpenAISpeechRequest) -> AsyncGenerator[bytes, None]: + """Stream audio chunks as they're generated""" + async for chunk in tts_service.generate_audio_stream( + text=request.input, + voice=request.voice, + speed=request.speed, + output_format=request.response_format + ): + yield chunk + @router.post("/audio/speech") async def create_speech( request: OpenAISpeechRequest, tts_service: TTSService = Depends(get_tts_service) @@ -31,24 +43,52 @@ async def create_speech( f"Voice '{request.voice}' not found. Available voices: {', '.join(sorted(available_voices))}" ) - # Generate audio directly using TTSService's method - audio, _ = tts_service._generate_audio( - text=request.input, - voice=request.voice, - speed=request.speed, - stitch_long_output=True, - ) + # Set content type based on format + content_type = { + "mp3": "audio/mpeg", + "opus": "audio/opus", + "aac": "audio/aac", + "flac": "audio/flac", + "wav": "audio/wav", + "pcm": "audio/pcm", + }.get(request.response_format, f"audio/{request.response_format}") - # Convert to requested format - content = AudioService.convert_audio(audio, 24000, request.response_format) + if request.stream: + # Stream audio chunks as they're generated + return StreamingResponse( + stream_audio_chunks(tts_service, request), + media_type=content_type, + headers={ + "Content-Disposition": f"attachment; filename=speech.{request.response_format}", + "X-Accel-Buffering": "no", # Disable proxy buffering + "Cache-Control": "no-cache", # Prevent caching + }, + ) + else: + # Generate complete audio + audio, _ = tts_service._generate_audio( + text=request.input, + voice=request.voice, + speed=request.speed, + stitch_long_output=True, + ) - return Response( - content=content, - media_type=f"audio/{request.response_format}", - headers={ - "Content-Disposition": f"attachment; filename=speech.{request.response_format}" - }, - ) + # Convert to requested format + content = AudioService.convert_audio( + audio, + 24000, + request.response_format, + is_first_chunk=True + ) + + return Response( + content=content, + media_type=content_type, + headers={ + "Content-Disposition": f"attachment; filename=speech.{request.response_format}", + "Cache-Control": "no-cache", # Prevent caching + }, + ) except ValueError as e: logger.error(f"Invalid request: {str(e)}") diff --git a/api/src/services/audio.py b/api/src/services/audio.py index b8cc708..4883eed 100644 --- a/api/src/services/audio.py +++ b/api/src/services/audio.py @@ -7,12 +7,35 @@ import soundfile as sf from loguru import logger +class AudioNormalizer: + """Handles audio normalization state for a single stream""" + def __init__(self): + self.int16_max = np.iinfo(np.int16).max + + def normalize(self, audio_data: np.ndarray) -> np.ndarray: + """Normalize audio data to int16 range""" + # Convert to float64 for accurate scaling + audio_float = audio_data.astype(np.float64) + + # Scale to int16 range while preserving relative amplitudes + max_val = np.abs(audio_float).max() + if max_val > 0: + scaling = self.int16_max / max_val + audio_float *= scaling + + # Clip to int16 range and convert + return np.clip(audio_float, -self.int16_max, self.int16_max).astype(np.int16) + class AudioService: """Service for audio format conversions""" - + @staticmethod def convert_audio( - audio_data: np.ndarray, sample_rate: int, output_format: str + audio_data: np.ndarray, + sample_rate: int, + output_format: str, + is_first_chunk: bool = True, + normalizer: AudioNormalizer = None ) -> bytes: """Convert audio data to specified format @@ -20,6 +43,7 @@ class AudioService: audio_data: Numpy array of audio samples sample_rate: Sample rate of the audio output_format: Target format (wav, mp3, opus, flac, pcm) + is_first_chunk: Whether this is the first chunk of a stream Returns: Bytes of the converted audio @@ -27,30 +51,34 @@ class AudioService: buffer = BytesIO() try: - if output_format == "wav": + # Normalize audio if normalizer provided, otherwise just convert to int16 + if normalizer is not None: + normalized_audio = normalizer.normalize(audio_data) + else: + normalized_audio = audio_data.astype(np.int16) + + if output_format == "pcm": + logger.info("Writing PCM data...") + # Raw 16-bit PCM samples, no header + buffer.write(normalized_audio.tobytes()) + elif output_format == "wav": logger.info("Writing to WAV format...") - # Ensure audio_data is in int16 format for WAV - audio_data_wav = ( - audio_data / np.abs(audio_data).max() * np.iinfo(np.int16).max - ).astype(np.int16) # Normalize - sf.write(buffer, audio_data_wav, sample_rate, format="WAV") - elif output_format == "mp3": - logger.info("Converting to MP3 format...") - # soundfile can write MP3 if ffmpeg or libsox is installed - sf.write(buffer, audio_data, sample_rate, format="MP3") + # Always include WAV header for WAV format + sf.write(buffer, normalized_audio, sample_rate, format="WAV", subtype='PCM_16') + elif output_format in ["mp3", "aac"]: + logger.info(f"Converting to {output_format.upper()} format...") + # Use lower bitrate for streaming + sf.write(buffer, normalized_audio, sample_rate, format=output_format.upper(), + subtype='COMPRESSED') elif output_format == "opus": logger.info("Converting to Opus format...") - sf.write(buffer, audio_data, sample_rate, format="OGG", subtype="OPUS") + # Use lower bitrate and smaller frame size for streaming + sf.write(buffer, normalized_audio, sample_rate, format="OGG", subtype="OPUS") elif output_format == "flac": logger.info("Converting to FLAC format...") - sf.write(buffer, audio_data, sample_rate, format="FLAC") - elif output_format == "pcm": - logger.info("Extracting PCM data...") - # Ensure audio_data is in int16 format for PCM - audio_data_pcm = ( - audio_data / np.abs(audio_data).max() * np.iinfo(np.int16).max - ).astype(np.int16) # Normalize - buffer.write(audio_data_pcm.tobytes()) + # Use smaller block size for streaming + sf.write(buffer, normalized_audio, sample_rate, format="FLAC", + subtype='PCM_16') else: raise ValueError( f"Format {output_format} not supported. Supported formats are: wav, mp3, opus, flac, pcm." diff --git a/api/src/services/text_processing/normalizer.py b/api/src/services/text_processing/normalizer.py index db5b7db..34f8d4b 100644 --- a/api/src/services/text_processing/normalizer.py +++ b/api/src/services/text_processing/normalizer.py @@ -1,4 +1,5 @@ import re +from functools import lru_cache def split_num(num: re.Match) -> str: """Handle number splitting for various formats""" @@ -48,6 +49,7 @@ def handle_decimal(num: re.Match) -> str: a, b = num.group().split(".") return " point ".join([a, " ".join(b)]) +@lru_cache(maxsize=1000) # Cache normalized text results def normalize_text(text: str) -> str: """Normalize text for TTS processing diff --git a/api/src/services/tts_service.py b/api/src/services/tts_service.py index 6d763fe..66c053b 100644 --- a/api/src/services/tts_service.py +++ b/api/src/services/tts_service.py @@ -3,6 +3,7 @@ import os import re import time from typing import List, Tuple, Optional +from functools import lru_cache import numpy as np import torch @@ -12,6 +13,7 @@ from loguru import logger from ..core.config import settings from .tts_model import TTSModel +from .audio import AudioService, AudioNormalizer class TTSService: @@ -24,6 +26,12 @@ class TTSService: text = str(text) if text is not None else "" return [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()] + @staticmethod + @lru_cache(maxsize=20) # Cache up to 8 most recently used voices + def _load_voice(voice_path: str) -> torch.Tensor: + """Load and cache a voice model""" + return torch.load(voice_path, map_location=TTSModel.get_device(), weights_only=True) + def _get_voice_path(self, voice_name: str) -> Optional[str]: """Get the path to a voice file""" voice_path = os.path.join(TTSModel.VOICES_DIR, f"{voice_name}.pt") @@ -31,6 +39,13 @@ class TTSService: def _generate_audio( self, text: str, voice: str, speed: float, stitch_long_output: bool = True + ) -> Tuple[torch.Tensor, float]: + """Generate complete audio and return with processing time""" + audio, processing_time = self._generate_audio_internal(text, voice, speed, stitch_long_output) + return audio, processing_time + + def _generate_audio_internal( + self, text: str, voice: str, speed: float, stitch_long_output: bool = True ) -> Tuple[torch.Tensor, float]: """Generate audio and measure processing time""" start_time = time.time() @@ -49,10 +64,8 @@ class TTSService: if not voice_path: raise ValueError(f"Voice not found: {voice}") - # Load voice - voicepack = torch.load( - voice_path, map_location=TTSModel.get_device(), weights_only=True - ) + # Load voice using cached loader + voicepack = self._load_voice(voice_path) # Generate audio with or without stitching if stitch_long_output: @@ -97,6 +110,78 @@ class TTSService: logger.error(f"Error in audio generation: {str(e)}") raise + async def generate_audio_stream( + self, text: str, voice: str, speed: float, output_format: str = "wav" + ): + """Generate and yield audio chunks as they're generated for real-time streaming""" + try: + # Create normalizer for consistent audio levels + stream_normalizer = AudioNormalizer() + + # Input validation and preprocessing + if not text: + raise ValueError("Text is empty") + normalized = normalize_text(text) + if not normalized: + raise ValueError("Text is empty after preprocessing") + text = str(normalized) + + # Voice validation and loading + voice_path = self._get_voice_path(voice) + if not voice_path: + raise ValueError(f"Voice not found: {voice}") + voicepack = self._load_voice(voice_path) + + # Split text into smaller chunks for faster streaming + # Use shorter chunks for real-time delivery + chunks = [] + sentences = self._split_text(text) + current_chunk = [] + current_length = 0 + target_length = 100 # Target ~100 characters per chunk for faster processing + + for sentence in sentences: + current_chunk.append(sentence) + current_length += len(sentence) + if current_length >= target_length: + chunks.append(" ".join(current_chunk)) + current_chunk = [] + current_length = 0 + + if current_chunk: + chunks.append(" ".join(current_chunk)) + + # Process and stream chunks + for i, chunk in enumerate(chunks): + try: + # Process text and generate audio + phonemes, tokens = TTSModel.process_text(chunk, voice[0]) + chunk_audio = TTSModel.generate_from_tokens(tokens, voicepack, speed) + + if chunk_audio is not None: + # Convert chunk with proper header handling + chunk_bytes = AudioService.convert_audio( + chunk_audio, + 24000, + output_format, + is_first_chunk=(i == 0), + normalizer=stream_normalizer + ) + yield chunk_bytes + else: + logger.error(f"No audio generated for chunk {i + 1}/{len(chunks)}") + + except Exception as e: + logger.error( + f"Failed to generate audio for chunk {i + 1}/{len(chunks)}: '{chunk}'. Error: {str(e)}" + ) + continue + + except Exception as e: + logger.error(f"Error in audio generation stream: {str(e)}") + raise + + def _save_audio(self, audio: torch.Tensor, filepath: str): """Save audio to file""" os.makedirs(os.path.dirname(filepath), exist_ok=True) diff --git a/api/src/structures/schemas.py b/api/src/structures/schemas.py index bc778bb..92d188e 100644 --- a/api/src/structures/schemas.py +++ b/api/src/structures/schemas.py @@ -22,7 +22,7 @@ class OpenAISpeechRequest(BaseModel): ) response_format: Literal["mp3", "opus", "aac", "flac", "wav", "pcm"] = Field( default="mp3", - description="The format to return audio in. Supported formats: mp3, opus, flac, wav. AAC and PCM are not currently supported.", + description="The format to return audio in. Supported formats: mp3, opus, flac, wav, pcm. PCM format returns raw 16-bit samples without headers. AAC is not currently supported.", ) speed: float = Field( default=1.0, @@ -30,3 +30,7 @@ class OpenAISpeechRequest(BaseModel): le=4.0, description="The speed of the generated audio. Select a value from 0.25 to 4.0.", ) + stream: bool = Field( + default=False, + description="If true, audio will be streamed as it's generated. Each chunk will be a complete sentence.", + ) diff --git a/docker-compose.yml b/docker-compose.yml index 2e7a86f..7308745 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,14 +46,14 @@ services: model-fetcher: condition: service_healthy - # Gradio UI service [Comment out everything below if you don't need it] - gradio-ui: - build: - context: ./ui - ports: - - "7860:7860" - volumes: - - ./ui/data:/app/ui/data - - ./ui/app.py:/app/app.py # Mount app.py for hot reload - environment: - - GRADIO_WATCH=True # Enable hot reloading + # # Gradio UI service [Comment out everything below if you don't need it] + # gradio-ui: + # build: + # context: ./ui + # ports: + # - "7860:7860" + # volumes: + # - ./ui/data:/app/ui/data + # - ./ui/app.py:/app/app.py # Mount app.py for hot reload + # environment: + # - GRADIO_WATCH=True # Enable hot reloading diff --git a/examples/assorted_checks/benchmarks/benchmark_first_token.py b/examples/assorted_checks/benchmarks/benchmark_first_token.py new file mode 100644 index 0000000..6709876 --- /dev/null +++ b/examples/assorted_checks/benchmarks/benchmark_first_token.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +import os +import time +import json +import numpy as np +import requests +import pandas as pd +from lib.shared_benchmark_utils import get_text_for_tokens, enc +from lib.shared_utils import save_json_results +from lib.shared_plotting import plot_correlation, plot_timeline + +def measure_first_token(text: str, output_dir: str, tokens: int, run_number: int) -> dict: + """Measure time to audio via API calls and save the audio output""" + results = { + "text_length": len(text), + "token_count": len(enc.encode(text)), + "total_time": None, + "time_to_first_chunk": None, + "error": None, + "audio_path": None, + "audio_length": None # Length of output audio in seconds + } + + try: + start_time = time.time() + + # Make request without streaming + response = requests.post( + "http://localhost:8880/v1/audio/speech", + json={ + "model": "kokoro", + "input": text, + "voice": "af", + "response_format": "wav", + "stream": False + }, + timeout=1800 + ) + response.raise_for_status() + + # Save complete audio + audio_filename = f"benchmark_tokens{tokens}_run{run_number}.wav" + audio_path = os.path.join(output_dir, audio_filename) + results["audio_path"] = audio_path + + content = response.content + with open(audio_path, 'wb') as f: + f.write(content) + + # Calculate audio length using scipy + import scipy.io.wavfile as wavfile + sample_rate, audio_data = wavfile.read(audio_path) + results["audio_length"] = len(audio_data) / sample_rate # Length in seconds + results["time_to_first_chunk"] = time.time() - start_time + + results["total_time"] = time.time() - start_time + return results + + except Exception as e: + results["error"] = str(e) + return results + +def main(): + # Set up paths + script_dir = os.path.dirname(os.path.abspath(__file__)) + output_dir = os.path.join(script_dir, "output_audio") + output_data_dir = os.path.join(script_dir, "output_data") + + # Create output directories + os.makedirs(output_dir, exist_ok=True) + os.makedirs(output_data_dir, exist_ok=True) + + # Load sample text + with open(os.path.join(script_dir, "the_time_machine_hg_wells.txt"), "r", encoding="utf-8") as f: + text = f.read() + + # Test specific token counts + token_sizes = [10, 25, 50, 100, 200, 500] + all_results = [] + + for tokens in token_sizes: + print(f"\nTesting {tokens} tokens") + test_text = get_text_for_tokens(text, tokens) + actual_tokens = len(enc.encode(test_text)) + print(f"Text preview: {test_text[:50]}...") + + # Run test 3 times for each size to get average + for i in range(5): + print(f"Run {i+1}/3...") + result = measure_first_token(test_text, output_dir, tokens, i + 1) + result["target_tokens"] = tokens + result["actual_tokens"] = actual_tokens + result["run_number"] = i + 1 + + print(f"Time to Audio: {result.get('time_to_first_chunk', 'N/A'):.3f}s") + print(f"Total time: {result.get('total_time', 'N/A'):.3f}s") + + if result["error"]: + print(f"Error: {result['error']}") + + all_results.append(result) + + # Calculate averages per token size + summary = {} + for tokens in token_sizes: + matching_results = [r for r in all_results if r["target_tokens"] == tokens and not r["error"]] + if matching_results: + avg_first_chunk = sum(r["time_to_first_chunk"] for r in matching_results) / len(matching_results) + avg_total = sum(r["total_time"] for r in matching_results) / len(matching_results) + avg_audio_length = sum(r["audio_length"] for r in matching_results) / len(matching_results) + summary[tokens] = { + "avg_time_to_first_chunk": round(avg_first_chunk, 3), + "avg_total_time": round(avg_total, 3), + "avg_audio_length": round(avg_audio_length, 3), + "num_successful_runs": len(matching_results) + } + + # Save results + # Save results + results_data = { + "individual_runs": all_results, + "summary": summary, + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S") + } + save_json_results( + results_data, + os.path.join(output_data_dir, "first_token_benchmark.json") + ) + + # Create plot directory if it doesn't exist + output_plots_dir = os.path.join(script_dir, "output_plots") + os.makedirs(output_plots_dir, exist_ok=True) + + # Create DataFrame for plotting + df = pd.DataFrame(all_results) + + # Create both plots + plot_correlation( + df, "target_tokens", "time_to_first_chunk", + "Time to Audio vs Input Size", + "Number of Input Tokens", + "Time to Audio (seconds)", + os.path.join(output_plots_dir, "first_token_latency.png") + ) + + plot_timeline( + df, + os.path.join(output_plots_dir, "first_token_timeline.png") + ) + + print("\nResults and plots saved to:") + print(f"- {os.path.join(output_data_dir, 'first_token_benchmark.json')}") + print(f"- {os.path.join(output_plots_dir, 'first_token_latency.png')}") + print(f"- {os.path.join(output_plots_dir, 'first_token_timeline.png')}") + +if __name__ == "__main__": + main() diff --git a/examples/assorted_checks/benchmarks/benchmark_first_token_stream.py b/examples/assorted_checks/benchmarks/benchmark_first_token_stream.py new file mode 100644 index 0000000..275cd91 --- /dev/null +++ b/examples/assorted_checks/benchmarks/benchmark_first_token_stream.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +import os +import time +import json +import numpy as np +import requests +import pandas as pd +from lib.shared_benchmark_utils import get_text_for_tokens, enc +from lib.shared_utils import save_json_results +from lib.shared_plotting import plot_correlation, plot_timeline + +def measure_first_token(text: str, output_dir: str, tokens: int, run_number: int) -> dict: + """Measure time to audio via API calls and save the audio output""" + results = { + "text_length": len(text), + "token_count": len(enc.encode(text)), + "total_time": None, + "time_to_first_chunk": None, + "error": None, + "audio_path": None, + "audio_length": None # Length of output audio in seconds + } + + try: + start_time = time.time() + + # Make request with streaming enabled + response = requests.post( + "http://localhost:8880/v1/audio/speech", + json={ + "model": "kokoro", + "input": text, + "voice": "af", + "response_format": "wav", + "stream": True + }, + stream=True, + timeout=1800 + ) + response.raise_for_status() + + # 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 + + first_chunk_time = None + chunks = [] + for chunk in response.iter_content(chunk_size=1024): + if chunk: + if first_chunk_time is None: + first_chunk_time = time.time() + results["time_to_first_chunk"] = first_chunk_time - start_time + chunks.append(chunk) + + # Extract WAV header and data separately + # First chunk has header + data, subsequent chunks are raw PCM + if not chunks: + raise ValueError("No audio chunks received") + + first_chunk = chunks[0] + remaining_chunks = chunks[1:] + + # Find end of WAV header (44 bytes for standard WAV) + header = first_chunk[:44] + first_data = first_chunk[44:] + + # Concatenate all PCM data + all_data = first_data + b''.join(remaining_chunks) + + # Update WAV header with total data size + import struct + data_size = len(all_data) + # Update data size field (bytes 4-7) + header = header[:4] + struct.pack('fT##a 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