NitinBot001 commited on
Commit
bf90fc9
·
verified ·
1 Parent(s): 4e58b7c

Upload 20 files

Browse files
Dockerfile CHANGED
@@ -1,36 +1,34 @@
1
- FROM python:3.11-slim
2
-
3
- WORKDIR /app
4
-
5
- ENV PYTHONDONTWRITEBYTECODE=1 \
6
- PYTHONUNBUFFERED=1 \
7
- PORT=8000
8
-
9
- # Install dependencies
10
- RUN apt-get update && apt-get install -y gcc curl git && rm -rf /var/lib/apt/lists/*
11
-
12
- # Copy source code first
13
- COPY ttsfm/ ./ttsfm/
14
- COPY ttsfm-web/ ./ttsfm-web/
15
- COPY pyproject.toml ./
16
- COPY requirements.txt ./
17
- COPY .git/ ./.git/
18
-
19
- # Install the TTSFM package with web dependencies
20
- RUN pip install --no-cache-dir -e .[web]
21
-
22
- # Install additional web dependencies
23
- RUN pip install --no-cache-dir python-dotenv>=1.0.0 flask-socketio>=5.3.0 python-socketio>=5.10.0 eventlet>=0.33.3
24
-
25
- # Create non-root user
26
- RUN useradd --create-home ttsfm && chown -R ttsfm:ttsfm /app
27
- USER ttsfm
28
-
29
- EXPOSE 8000
30
-
31
- HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
32
- CMD curl -f http://localhost:8000/api/health || exit 1
33
-
34
- WORKDIR /app/ttsfm-web
35
- # Use run.py for proper eventlet initialization
36
- CMD ["python", "app.py"]
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies
6
+ RUN apt-get update && apt-get install -y \
7
+ gcc \
8
+ g++ \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Copy requirements and install Python dependencies
12
+ COPY requirements.txt .
13
+ RUN pip install --no-cache-dir -r requirements.txt
14
+
15
+ # Create necessary directories
16
+ RUN mkdir -p static data
17
+
18
+ # Copy application files
19
+ COPY app.py .
20
+ COPY translations/ translations/
21
+ COPY i18n.py .
22
+ COPY websocket_handler.py .
23
+ COPY static/ static/
24
+ COPY data/ data/
25
+
26
+ # Create a non-root user
27
+ RUN useradd -m -u 1000 user && chown -R user:user /app
28
+ USER user
29
+
30
+ # Expose port for Hugging Face Spaces
31
+ EXPOSE 7860
32
+
33
+ # Run the application
34
+ CMD ["python", "app.py"]
 
 
__pycache__/i18n.cpython-313.pyc ADDED
Binary file (9.46 kB). View file
 
__pycache__/websocket_handler.cpython-313.pyc ADDED
Binary file (10.3 kB). View file
 
app.py ADDED
@@ -0,0 +1,988 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TTSFM Web Application
3
+
4
+ A Flask web application that provides a user-friendly interface
5
+ for the TTSFM text-to-speech package.
6
+ """
7
+
8
+ import os
9
+ import json
10
+ import logging
11
+ import tempfile
12
+ import io
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Dict, Any, Optional, List
16
+ from functools import wraps
17
+ from urllib.parse import urlparse, urljoin
18
+
19
+ from flask import Flask, request, jsonify, send_file, Response, render_template, redirect, url_for
20
+ from flask_cors import CORS
21
+ from flask_socketio import SocketIO
22
+ from dotenv import load_dotenv
23
+
24
+ # Import i18n support
25
+ from i18n import init_i18n, get_locale, set_locale, _
26
+
27
+ # Import the TTSFM package
28
+ try:
29
+ from ttsfm import TTSClient, Voice, AudioFormat, TTSException
30
+ from ttsfm.exceptions import APIException, NetworkException, ValidationException
31
+ from ttsfm.utils import validate_text_length, split_text_by_length
32
+ except ImportError:
33
+ # Fallback for development when package is not installed
34
+ import sys
35
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
36
+ from ttsfm import TTSClient, Voice, AudioFormat, TTSException
37
+ from ttsfm.exceptions import APIException, NetworkException, ValidationException
38
+ from ttsfm.utils import validate_text_length, split_text_by_length
39
+
40
+ # Load environment variables
41
+ load_dotenv()
42
+
43
+ # Configure logging
44
+ logging.basicConfig(
45
+ level=logging.INFO,
46
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
47
+ )
48
+ logger = logging.getLogger(__name__)
49
+
50
+ # Create Flask app
51
+ app = Flask(__name__, static_folder='static', static_url_path='/static')
52
+ app.secret_key = os.getenv("SECRET_KEY", "ttsfm-secret-key-change-in-production")
53
+ CORS(app)
54
+
55
+ # Configuration (moved up for socketio initialization)
56
+ HOST = os.getenv("HOST", "localhost")
57
+ PORT = int(os.getenv("PORT", "7860"))
58
+ DEBUG = os.getenv("DEBUG", "true").lower() == "true"
59
+
60
+ # Initialize SocketIO with proper async mode
61
+ # Using eventlet for production, threading for development
62
+ async_mode = 'eventlet' if not DEBUG else 'threading'
63
+ socketio = SocketIO(app, cors_allowed_origins="*", async_mode=async_mode)
64
+
65
+ # Initialize i18n support
66
+ init_i18n(app)
67
+
68
+ # API Key configuration
69
+ API_KEY = os.getenv("TTSFM_API_KEY") # Set this environment variable for API protection
70
+ REQUIRE_API_KEY = os.getenv("REQUIRE_API_KEY", "false").lower() == "true"
71
+
72
+ # Create TTS client - now uses openai.fm directly, no configuration needed
73
+ tts_client = TTSClient()
74
+
75
+ # Initialize WebSocket handler
76
+ from websocket_handler import WebSocketTTSHandler
77
+ websocket_handler = WebSocketTTSHandler(socketio, tts_client)
78
+
79
+ logger.info("Initialized web app with TTSFM using openai.fm free service")
80
+ logger.info(f"WebSocket support enabled with {async_mode} async mode")
81
+
82
+ # API Key validation decorator
83
+ def require_api_key(f):
84
+ """Decorator to require API key for protected endpoints."""
85
+ @wraps(f)
86
+ def decorated_function(*args, **kwargs):
87
+ # Skip API key check if not required
88
+ if not REQUIRE_API_KEY:
89
+ return f(*args, **kwargs)
90
+
91
+ # Check if API key is configured
92
+ if not API_KEY:
93
+ logger.warning("API key protection is enabled but TTSFM_API_KEY is not set")
94
+ return jsonify({
95
+ "error": "API key protection is enabled but not configured properly"
96
+ }), 500
97
+
98
+ # Get API key from request headers - prioritize Authorization header (OpenAI compatible)
99
+ provided_key = None
100
+
101
+ # 1. Check Authorization header first (OpenAI standard)
102
+ auth_header = request.headers.get('Authorization')
103
+ if auth_header and auth_header.startswith('Bearer '):
104
+ provided_key = auth_header[7:] # Remove 'Bearer ' prefix
105
+
106
+ # 2. Check X-API-Key header as fallback
107
+ if not provided_key:
108
+ provided_key = request.headers.get('X-API-Key')
109
+
110
+ # 3. Check API key from query parameters as fallback
111
+ if not provided_key:
112
+ provided_key = request.args.get('api_key')
113
+
114
+ # 4. Check API key from JSON body as fallback
115
+ if not provided_key and request.is_json:
116
+ data = request.get_json(silent=True)
117
+ if data:
118
+ provided_key = data.get('api_key')
119
+
120
+ # Validate API key
121
+ if not provided_key or provided_key != API_KEY:
122
+ logger.warning(f"Invalid API key attempt from {request.remote_addr}")
123
+ return jsonify({
124
+ "error": {
125
+ "message": "Invalid API key provided",
126
+ "type": "invalid_request_error",
127
+ "code": "invalid_api_key"
128
+ }
129
+ }), 401
130
+
131
+ return f(*args, **kwargs)
132
+ return decorated_function
133
+
134
+ def combine_audio_chunks(audio_chunks: List[bytes], format_type: str = "mp3") -> bytes:
135
+ """
136
+ Combine multiple audio chunks into a single audio file.
137
+
138
+ Args:
139
+ audio_chunks: List of audio data as bytes
140
+ format_type: Audio format (mp3, wav, etc.)
141
+
142
+ Returns:
143
+ bytes: Combined audio data
144
+ """
145
+ try:
146
+ # Try to use pydub for audio processing if available
147
+ try:
148
+ from pydub import AudioSegment
149
+
150
+ # Convert each chunk to AudioSegment
151
+ audio_segments = []
152
+ for chunk in audio_chunks:
153
+ if format_type.lower() == "mp3":
154
+ segment = AudioSegment.from_mp3(io.BytesIO(chunk))
155
+ elif format_type.lower() == "wav":
156
+ segment = AudioSegment.from_wav(io.BytesIO(chunk))
157
+ elif format_type.lower() == "opus":
158
+ # For OPUS, we'll treat it as WAV since openai.fm returns WAV for OPUS requests
159
+ segment = AudioSegment.from_wav(io.BytesIO(chunk))
160
+ else:
161
+ # For other formats, try to auto-detect or default to WAV
162
+ try:
163
+ segment = AudioSegment.from_file(io.BytesIO(chunk))
164
+ except:
165
+ segment = AudioSegment.from_wav(io.BytesIO(chunk))
166
+
167
+ audio_segments.append(segment)
168
+
169
+ # Combine all segments
170
+ combined = audio_segments[0]
171
+ for segment in audio_segments[1:]:
172
+ combined += segment
173
+
174
+ # Export to bytes
175
+ output_buffer = io.BytesIO()
176
+ if format_type.lower() == "mp3":
177
+ combined.export(output_buffer, format="mp3")
178
+ elif format_type.lower() == "wav":
179
+ combined.export(output_buffer, format="wav")
180
+ else:
181
+ # Default to the original format or WAV
182
+ try:
183
+ combined.export(output_buffer, format=format_type.lower())
184
+ except:
185
+ combined.export(output_buffer, format="wav")
186
+
187
+ return output_buffer.getvalue()
188
+
189
+ except ImportError:
190
+ # Fallback: Simple concatenation for WAV files
191
+ logger.warning("pydub not available, using simple concatenation for WAV files")
192
+
193
+ if format_type.lower() == "wav":
194
+ return _simple_wav_concatenation(audio_chunks)
195
+ else:
196
+ # For non-WAV formats without pydub, just concatenate raw bytes
197
+ # This won't produce valid audio but is better than failing
198
+ logger.warning(f"Cannot properly combine {format_type} files without pydub, using raw concatenation")
199
+ return b''.join(audio_chunks)
200
+
201
+ except Exception as e:
202
+ logger.error(f"Error combining audio chunks: {e}")
203
+ # Fallback to simple concatenation
204
+ return b''.join(audio_chunks)
205
+
206
+ def _simple_wav_concatenation(wav_chunks: List[bytes]) -> bytes:
207
+ """
208
+ Simple WAV file concatenation without external dependencies.
209
+ This is a basic implementation that works for simple WAV files.
210
+ """
211
+ if not wav_chunks:
212
+ return b''
213
+
214
+ if len(wav_chunks) == 1:
215
+ return wav_chunks[0]
216
+
217
+ try:
218
+ # For WAV files, we can do a simple concatenation by:
219
+ # 1. Taking the header from the first file
220
+ # 2. Concatenating all the audio data
221
+ # 3. Updating the file size in the header
222
+
223
+ first_wav = wav_chunks[0]
224
+ if len(first_wav) < 44: # WAV header is at least 44 bytes
225
+ return b''.join(wav_chunks)
226
+
227
+ # Extract header from first file (first 44 bytes)
228
+ header = bytearray(first_wav[:44])
229
+
230
+ # Collect all audio data (skip headers for subsequent files)
231
+ audio_data = first_wav[44:] # Audio data from first file
232
+
233
+ for wav_chunk in wav_chunks[1:]:
234
+ if len(wav_chunk) > 44:
235
+ audio_data += wav_chunk[44:] # Skip header, append audio data
236
+
237
+ # Update file size in header (bytes 4-7)
238
+ total_size = len(header) + len(audio_data) - 8
239
+ header[4:8] = total_size.to_bytes(4, byteorder='little')
240
+
241
+ # Update data chunk size in header (bytes 40-43)
242
+ data_size = len(audio_data)
243
+ header[40:44] = data_size.to_bytes(4, byteorder='little')
244
+
245
+ return bytes(header) + audio_data
246
+
247
+ except Exception as e:
248
+ logger.error(f"Error in simple WAV concatenation: {e}")
249
+ # Ultimate fallback
250
+ return b''.join(wav_chunks)
251
+
252
+ def _is_safe_url(target: Optional[str]) -> bool:
253
+ """Validate that a target URL is safe for redirection.
254
+
255
+ Allows only relative URLs or absolute URLs that match this server's host
256
+ and http/https schemes. Prevents open redirects to external domains.
257
+ """
258
+ if not target:
259
+ return False
260
+
261
+ parsed = urlparse(target)
262
+ if parsed.scheme or parsed.netloc or target.startswith('//'):
263
+ return False
264
+ if not parsed.path.startswith('/'):
265
+ return False
266
+ joined = urljoin(request.host_url, target)
267
+ host = urlparse(request.host_url)
268
+ j = urlparse(joined)
269
+ return j.scheme in ("http", "https") and j.netloc == host.netloc
270
+
271
+ @app.route('/set-language/<lang_code>')
272
+ def set_language(lang_code):
273
+ """Set the user's language preference."""
274
+ if set_locale(lang_code):
275
+ # Redirect back only if the referrer is safe; otherwise go home
276
+ target = request.referrer
277
+ if _is_safe_url(target):
278
+ return redirect(target)
279
+ return redirect(url_for('index'))
280
+ else:
281
+ # Invalid language code, redirect to home
282
+ return redirect(url_for('index'))
283
+
284
+ @app.route('/')
285
+ def index():
286
+ """Serve the main web interface."""
287
+ return render_template('index.html')
288
+
289
+ @app.route('/playground')
290
+ def playground():
291
+ """Serve the interactive playground."""
292
+ return render_template('playground.html')
293
+
294
+ @app.route('/docs')
295
+ def docs():
296
+ """Serve the API documentation."""
297
+ return render_template('docs.html')
298
+
299
+ @app.route('/websocket-demo')
300
+ def websocket_demo():
301
+ """Serve the WebSocket streaming demo page."""
302
+ return render_template('websocket_demo.html')
303
+
304
+ @app.route('/api/voices', methods=['GET'])
305
+ def get_voices():
306
+ """Get list of available voices."""
307
+ try:
308
+ voices = [
309
+ {
310
+ "id": voice.value,
311
+ "name": voice.value.title(),
312
+ "description": f"{voice.value.title()} voice"
313
+ }
314
+ for voice in Voice
315
+ ]
316
+
317
+ return jsonify({
318
+ "voices": voices,
319
+ "count": len(voices)
320
+ })
321
+
322
+ except Exception as e:
323
+ logger.error(f"Error getting voices: {e}")
324
+ return jsonify({"error": "Failed to get voices"}), 500
325
+
326
+ @app.route('/api/formats', methods=['GET'])
327
+ def get_formats():
328
+ """Get list of supported audio formats."""
329
+ try:
330
+ formats = [
331
+ {
332
+ "id": "mp3",
333
+ "name": "MP3",
334
+ "mime_type": "audio/mpeg",
335
+ "description": "MP3 audio format - good quality, small file size",
336
+ "quality": "Good",
337
+ "file_size": "Small",
338
+ "use_case": "Web, mobile apps, general use"
339
+ },
340
+ {
341
+ "id": "opus",
342
+ "name": "OPUS",
343
+ "mime_type": "audio/opus",
344
+ "description": "OPUS audio format - excellent quality, small file size",
345
+ "quality": "Excellent",
346
+ "file_size": "Small",
347
+ "use_case": "Web streaming, VoIP"
348
+ },
349
+ {
350
+ "id": "aac",
351
+ "name": "AAC",
352
+ "mime_type": "audio/aac",
353
+ "description": "AAC audio format - good quality, medium file size",
354
+ "quality": "Good",
355
+ "file_size": "Medium",
356
+ "use_case": "Apple devices, streaming"
357
+ },
358
+ {
359
+ "id": "flac",
360
+ "name": "FLAC",
361
+ "mime_type": "audio/flac",
362
+ "description": "FLAC audio format - lossless quality, large file size",
363
+ "quality": "Lossless",
364
+ "file_size": "Large",
365
+ "use_case": "High-quality archival"
366
+ },
367
+ {
368
+ "id": "wav",
369
+ "name": "WAV",
370
+ "mime_type": "audio/wav",
371
+ "description": "WAV audio format - lossless quality, large file size",
372
+ "quality": "Lossless",
373
+ "file_size": "Large",
374
+ "use_case": "Professional audio"
375
+ },
376
+ {
377
+ "id": "pcm",
378
+ "name": "PCM",
379
+ "mime_type": "audio/pcm",
380
+ "description": "PCM audio format - raw audio data, large file size",
381
+ "quality": "Raw",
382
+ "file_size": "Large",
383
+ "use_case": "Audio processing"
384
+ }
385
+ ]
386
+
387
+ return jsonify({
388
+ "formats": formats,
389
+ "count": len(formats)
390
+ })
391
+
392
+ except Exception as e:
393
+ logger.error(f"Error getting formats: {e}")
394
+ return jsonify({"error": "Failed to get formats"}), 500
395
+
396
+ @app.route('/api/validate-text', methods=['POST'])
397
+ def validate_text():
398
+ """Validate text length and provide splitting suggestions."""
399
+ try:
400
+ data = request.get_json()
401
+ if not data:
402
+ return jsonify({"error": "No JSON data provided"}), 400
403
+
404
+ text = data.get('text', '').strip()
405
+ max_length = data.get('max_length', 4096)
406
+
407
+ if not text:
408
+ return jsonify({"error": "Text is required"}), 400
409
+
410
+ text_length = len(text)
411
+ is_valid = text_length <= max_length
412
+
413
+ result = {
414
+ "text_length": text_length,
415
+ "max_length": max_length,
416
+ "is_valid": is_valid,
417
+ "needs_splitting": not is_valid
418
+ }
419
+
420
+ if not is_valid:
421
+ # Provide splitting suggestions
422
+ chunks = split_text_by_length(text, max_length, preserve_words=True)
423
+ result.update({
424
+ "suggested_chunks": len(chunks),
425
+ "chunk_preview": [chunk[:100] + "..." if len(chunk) > 100 else chunk for chunk in chunks[:3]]
426
+ })
427
+
428
+ return jsonify(result)
429
+
430
+ except Exception as e:
431
+ logger.error(f"Text validation error: {e}")
432
+ return jsonify({"error": "Text validation failed"}), 500
433
+
434
+ @app.route('/api/generate', methods=['POST'])
435
+ @require_api_key
436
+ def generate_speech():
437
+ """Generate speech from text using the TTSFM package."""
438
+ try:
439
+ # Parse request data
440
+ data = request.get_json()
441
+ if not data:
442
+ return jsonify({"error": "No JSON data provided"}), 400
443
+
444
+ # Extract parameters
445
+ text = data.get('text', '').strip()
446
+ voice = data.get('voice', Voice.ALLOY.value)
447
+ response_format = data.get('format', AudioFormat.MP3.value)
448
+ instructions = data.get('instructions', '').strip() or None
449
+ max_length = data.get('max_length', 4096)
450
+ validate_length = data.get('validate_length', True)
451
+
452
+ # Validate required fields
453
+ if not text:
454
+ return jsonify({"error": "Text is required"}), 400
455
+
456
+ # Validate voice
457
+ try:
458
+ voice_enum = Voice(voice.lower())
459
+ except ValueError:
460
+ return jsonify({
461
+ "error": f"Invalid voice: {voice}. Must be one of: {[v.value for v in Voice]}"
462
+ }), 400
463
+
464
+ # Validate format
465
+ try:
466
+ format_enum = AudioFormat(response_format.lower())
467
+ except ValueError:
468
+ return jsonify({
469
+ "error": f"Invalid format: {response_format}. Must be one of: {[f.value for f in AudioFormat]}"
470
+ }), 400
471
+
472
+ logger.info(f"Generating speech: text='{text[:50]}...', voice={voice}, format={response_format}")
473
+
474
+ # Generate speech using the TTSFM package with validation
475
+ response = tts_client.generate_speech(
476
+ text=text,
477
+ voice=voice_enum,
478
+ response_format=format_enum,
479
+ instructions=instructions,
480
+ max_length=max_length,
481
+ validate_length=validate_length
482
+ )
483
+
484
+ # Return audio data
485
+ return Response(
486
+ response.audio_data,
487
+ mimetype=response.content_type,
488
+ headers={
489
+ 'Content-Disposition': f'attachment; filename="speech.{response.format.value}"',
490
+ 'Content-Length': str(response.size),
491
+ 'X-Audio-Format': response.format.value,
492
+ 'X-Audio-Size': str(response.size)
493
+ }
494
+ )
495
+
496
+ except ValidationException as e:
497
+ logger.warning(f"Validation error: {e}")
498
+ return jsonify({"error": "Invalid input parameters"}), 400
499
+
500
+ except APIException as e:
501
+ logger.error(f"API error: {e}")
502
+ return jsonify({
503
+ "error": "TTS service error",
504
+ "status_code": getattr(e, 'status_code', 500)
505
+ }), getattr(e, 'status_code', 500)
506
+
507
+ except NetworkException as e:
508
+ logger.error(f"Network error: {e}")
509
+ return jsonify({
510
+ "error": "TTS service is currently unavailable"
511
+ }), 503
512
+
513
+ except TTSException as e:
514
+ logger.error(f"TTS error: {e}")
515
+ return jsonify({"error": "Text-to-speech generation failed"}), 500
516
+
517
+ except Exception as e:
518
+ logger.error(f"Unexpected error: {e}")
519
+ return jsonify({"error": "Internal server error"}), 500
520
+
521
+
522
+
523
+ @app.route('/api/generate-combined', methods=['POST'])
524
+ @require_api_key
525
+ def generate_speech_combined():
526
+ """Generate speech from long text and return a single combined audio file."""
527
+ try:
528
+ data = request.get_json()
529
+ if not data:
530
+ return jsonify({"error": "No JSON data provided"}), 400
531
+
532
+ text = data.get('text', '').strip()
533
+ voice = data.get('voice', Voice.ALLOY.value)
534
+ response_format = data.get('format', AudioFormat.MP3.value)
535
+ instructions = data.get('instructions', '').strip() or None
536
+ max_length = data.get('max_length', 4096)
537
+ preserve_words = data.get('preserve_words', True)
538
+
539
+ if not text:
540
+ return jsonify({"error": "Text is required"}), 400
541
+
542
+ # Check if text needs splitting
543
+ if len(text) <= max_length:
544
+ # Text is short enough, use regular generation
545
+ try:
546
+ voice_enum = Voice(voice.lower())
547
+ format_enum = AudioFormat(response_format.lower())
548
+ except ValueError as e:
549
+ logger.warning(f"Invalid voice or format: {e}")
550
+ return jsonify({"error": "Invalid voice or format specified"}), 400
551
+
552
+ response = tts_client.generate_speech(
553
+ text=text,
554
+ voice=voice_enum,
555
+ response_format=format_enum,
556
+ instructions=instructions,
557
+ max_length=max_length,
558
+ validate_length=True
559
+ )
560
+
561
+ return Response(
562
+ response.audio_data,
563
+ mimetype=response.content_type,
564
+ headers={
565
+ 'Content-Disposition': f'attachment; filename="combined_speech.{response.format.value}"',
566
+ 'Content-Length': str(response.size),
567
+ 'X-Audio-Format': response.format.value,
568
+ 'X-Audio-Size': str(response.size),
569
+ 'X-Chunks-Combined': '1'
570
+ }
571
+ )
572
+
573
+ # Text is long, split and combine
574
+ try:
575
+ voice_enum = Voice(voice.lower())
576
+ format_enum = AudioFormat(response_format.lower())
577
+ except ValueError as e:
578
+ logger.warning(f"Invalid voice or format: {e}")
579
+ return jsonify({"error": "Invalid voice or format specified"}), 400
580
+
581
+ logger.info(f"Generating combined speech for long text: {len(text)} characters, splitting into chunks")
582
+
583
+ # Generate speech chunks
584
+ try:
585
+ responses = tts_client.generate_speech_long_text(
586
+ text=text,
587
+ voice=voice_enum,
588
+ response_format=format_enum,
589
+ instructions=instructions,
590
+ max_length=max_length,
591
+ preserve_words=preserve_words
592
+ )
593
+ except Exception as e:
594
+ logger.error(f"Long text generation failed: {e}")
595
+ return jsonify({"error": "Long text generation failed"}), 500
596
+
597
+ if not responses:
598
+ return jsonify({"error": "No valid text chunks found"}), 400
599
+
600
+ logger.info(f"Generated {len(responses)} chunks, combining into single audio file")
601
+
602
+ # Extract audio data from responses
603
+ audio_chunks = [response.audio_data for response in responses]
604
+
605
+ # Combine audio chunks
606
+ try:
607
+ combined_audio = combine_audio_chunks(audio_chunks, format_enum.value)
608
+ except Exception as e:
609
+ logger.error(f"Failed to combine audio chunks: {e}")
610
+ return jsonify({"error": "Failed to combine audio chunks"}), 500
611
+
612
+ if not combined_audio:
613
+ return jsonify({"error": "Failed to generate combined audio"}), 500
614
+
615
+ # Determine content type
616
+ content_type = responses[0].content_type # Use content type from first chunk
617
+
618
+ logger.info(f"Successfully combined {len(responses)} chunks into single audio file ({len(combined_audio)} bytes)")
619
+
620
+ return Response(
621
+ combined_audio,
622
+ mimetype=content_type,
623
+ headers={
624
+ 'Content-Disposition': f'attachment; filename="combined_speech.{format_enum.value}"',
625
+ 'Content-Length': str(len(combined_audio)),
626
+ 'X-Audio-Format': format_enum.value,
627
+ 'X-Audio-Size': str(len(combined_audio)),
628
+ 'X-Chunks-Combined': str(len(responses)),
629
+ 'X-Original-Text-Length': str(len(text))
630
+ }
631
+ )
632
+
633
+ except ValidationException as e:
634
+ logger.warning(f"Validation error: {e}")
635
+ return jsonify({"error": "Invalid input parameters"}), 400
636
+
637
+ except APIException as e:
638
+ logger.error(f"API error: {e}")
639
+ return jsonify({
640
+ "error": "TTS service error",
641
+ "status_code": getattr(e, 'status_code', 500)
642
+ }), getattr(e, 'status_code', 500)
643
+
644
+ except NetworkException as e:
645
+ logger.error(f"Network error: {e}")
646
+ return jsonify({
647
+ "error": "TTS service is currently unavailable"
648
+ }), 503
649
+
650
+ except TTSException as e:
651
+ logger.error(f"TTS error: {e}")
652
+ return jsonify({"error": "Text-to-speech generation failed"}), 500
653
+
654
+ except Exception as e:
655
+ logger.error(f"Combined generation error: {e}")
656
+ return jsonify({"error": "Combined audio generation failed"}), 500
657
+
658
+ @app.route('/api/status', methods=['GET'])
659
+ def get_status():
660
+ """Get service status."""
661
+ try:
662
+ # Try to make a simple request to check if the TTS service is available
663
+ test_response = tts_client.generate_speech(
664
+ text="test",
665
+ voice=Voice.ALLOY,
666
+ response_format=AudioFormat.MP3
667
+ )
668
+
669
+ return jsonify({
670
+ "status": "online",
671
+ "tts_service": "openai.fm (free)",
672
+ "package_version": "3.2.3",
673
+ "timestamp": datetime.now().isoformat()
674
+ })
675
+
676
+ except Exception as e:
677
+ logger.error(f"Status check failed: {e}")
678
+ return jsonify({
679
+ "status": "error",
680
+ "tts_service": "openai.fm (free)",
681
+ "error": "Service status check failed",
682
+ "timestamp": datetime.now().isoformat()
683
+ }), 503
684
+
685
+ @app.route('/api/health', methods=['GET'])
686
+ def health_check():
687
+ """Simple health check endpoint."""
688
+ return jsonify({
689
+ "status": "healthy",
690
+ "package_version": "3.2.3",
691
+ "timestamp": datetime.now().isoformat()
692
+ })
693
+
694
+ @app.route('/api/websocket/status', methods=['GET'])
695
+ def websocket_status():
696
+ """Get WebSocket server status and active connections."""
697
+ return jsonify({
698
+ "websocket_enabled": True,
699
+ "async_mode": async_mode,
700
+ "active_sessions": websocket_handler.get_active_sessions_count(),
701
+ "transport_options": ["websocket", "polling"],
702
+ "endpoint": f"ws{'s' if request.is_secure else ''}://{request.host}/socket.io/",
703
+ "timestamp": datetime.now().isoformat()
704
+ })
705
+
706
+ @app.route('/api/auth-status', methods=['GET'])
707
+ def auth_status():
708
+ """Get authentication status and requirements."""
709
+ return jsonify({
710
+ "api_key_required": REQUIRE_API_KEY,
711
+ "api_key_configured": bool(API_KEY) if REQUIRE_API_KEY else None,
712
+ "timestamp": datetime.now().isoformat()
713
+ })
714
+
715
+ @app.route('/api/translations/<lang_code>', methods=['GET'])
716
+ def get_translations(lang_code):
717
+ """Get translations for a specific language."""
718
+ try:
719
+ if hasattr(app, 'language_manager'):
720
+ translations = app.language_manager.translations.get(lang_code, {})
721
+ return jsonify(translations)
722
+ else:
723
+ return jsonify({}), 404
724
+ except Exception as e:
725
+ logger.error(f"Error getting translations for {lang_code}: {e}")
726
+ return jsonify({"error": "Failed to get translations"}), 500
727
+
728
+ # OpenAI-compatible API endpoints
729
+ @app.route('/v1/audio/speech', methods=['POST'])
730
+ @require_api_key
731
+ def openai_speech():
732
+ """OpenAI-compatible speech generation endpoint with auto-combine feature."""
733
+ try:
734
+ # Parse request data
735
+ data = request.get_json()
736
+ if not data:
737
+ return jsonify({
738
+ "error": {
739
+ "message": "No JSON data provided",
740
+ "type": "invalid_request_error",
741
+ "code": "missing_data"
742
+ }
743
+ }), 400
744
+
745
+ # Extract OpenAI-compatible parameters
746
+ model = data.get('model', 'gpt-4o-mini-tts') # Accept but ignore model
747
+ input_text = data.get('input', '').strip()
748
+ voice = data.get('voice', 'alloy')
749
+ response_format = data.get('response_format', 'mp3')
750
+ instructions = data.get('instructions', '').strip() or None
751
+ speed = data.get('speed', 1.0) # Accept but ignore speed
752
+
753
+ # TTSFM-specific parameters
754
+ auto_combine = data.get('auto_combine', True) # New parameter: auto-combine long text (default: True)
755
+ max_length = data.get('max_length', 4096) # Custom parameter for chunk size
756
+
757
+ # Validate required fields
758
+ if not input_text:
759
+ return jsonify({
760
+ "error": {
761
+ "message": "Input text is required",
762
+ "type": "invalid_request_error",
763
+ "code": "missing_input"
764
+ }
765
+ }), 400
766
+
767
+ # Validate voice
768
+ try:
769
+ voice_enum = Voice(voice.lower())
770
+ except ValueError:
771
+ return jsonify({
772
+ "error": {
773
+ "message": f"Invalid voice: {voice}. Must be one of: {[v.value for v in Voice]}",
774
+ "type": "invalid_request_error",
775
+ "code": "invalid_voice"
776
+ }
777
+ }), 400
778
+
779
+ # Validate format
780
+ try:
781
+ format_enum = AudioFormat(response_format.lower())
782
+ except ValueError:
783
+ return jsonify({
784
+ "error": {
785
+ "message": f"Invalid response_format: {response_format}. Must be one of: {[f.value for f in AudioFormat]}",
786
+ "type": "invalid_request_error",
787
+ "code": "invalid_format"
788
+ }
789
+ }), 400
790
+
791
+ logger.info(f"OpenAI API: Generating speech: text='{input_text[:50]}...', voice={voice}, format={response_format}, auto_combine={auto_combine}")
792
+
793
+ # Check if text exceeds limit and auto_combine is enabled
794
+ if len(input_text) > max_length and auto_combine:
795
+ # Long text with auto-combine enabled: split and combine
796
+ logger.info(f"Long text detected ({len(input_text)} chars), auto-combining enabled")
797
+
798
+ # Generate speech chunks
799
+ responses = tts_client.generate_speech_long_text(
800
+ text=input_text,
801
+ voice=voice_enum,
802
+ response_format=format_enum,
803
+ instructions=instructions,
804
+ max_length=max_length,
805
+ preserve_words=True
806
+ )
807
+
808
+ if not responses:
809
+ return jsonify({
810
+ "error": {
811
+ "message": "No valid text chunks found",
812
+ "type": "processing_error",
813
+ "code": "no_chunks"
814
+ }
815
+ }), 400
816
+
817
+ # Extract audio data and combine
818
+ audio_chunks = [response.audio_data for response in responses]
819
+ combined_audio = combine_audio_chunks(audio_chunks, format_enum.value)
820
+
821
+ if not combined_audio:
822
+ return jsonify({
823
+ "error": {
824
+ "message": "Failed to combine audio chunks",
825
+ "type": "processing_error",
826
+ "code": "combine_failed"
827
+ }
828
+ }), 500
829
+
830
+ content_type = responses[0].content_type
831
+
832
+ logger.info(f"Successfully combined {len(responses)} chunks into single audio file")
833
+
834
+ return Response(
835
+ combined_audio,
836
+ mimetype=content_type,
837
+ headers={
838
+ 'Content-Type': content_type,
839
+ 'Content-Length': str(len(combined_audio)),
840
+ 'X-Audio-Format': format_enum.value,
841
+ 'X-Audio-Size': str(len(combined_audio)),
842
+ 'X-Chunks-Combined': str(len(responses)),
843
+ 'X-Original-Text-Length': str(len(input_text)),
844
+ 'X-Auto-Combine': 'true',
845
+ 'X-Powered-By': 'TTSFM-OpenAI-Compatible'
846
+ }
847
+ )
848
+
849
+ else:
850
+ # Short text or auto_combine disabled: use regular generation
851
+ if len(input_text) > max_length and not auto_combine:
852
+ # Text is too long but auto_combine is disabled - return error
853
+ return jsonify({
854
+ "error": {
855
+ "message": f"Input text is too long ({len(input_text)} characters). Maximum allowed length is {max_length} characters. Enable auto_combine parameter to automatically split and combine long text.",
856
+ "type": "invalid_request_error",
857
+ "code": "text_too_long"
858
+ }
859
+ }), 400
860
+
861
+ # Generate speech using the TTSFM package
862
+ response = tts_client.generate_speech(
863
+ text=input_text,
864
+ voice=voice_enum,
865
+ response_format=format_enum,
866
+ instructions=instructions,
867
+ max_length=max_length,
868
+ validate_length=True
869
+ )
870
+
871
+ # Return audio data in OpenAI format
872
+ return Response(
873
+ response.audio_data,
874
+ mimetype=response.content_type,
875
+ headers={
876
+ 'Content-Type': response.content_type,
877
+ 'Content-Length': str(response.size),
878
+ 'X-Audio-Format': response.format.value,
879
+ 'X-Audio-Size': str(response.size),
880
+ 'X-Chunks-Combined': '1',
881
+ 'X-Auto-Combine': str(auto_combine).lower(),
882
+ 'X-Powered-By': 'TTSFM-OpenAI-Compatible'
883
+ }
884
+ )
885
+
886
+ except ValidationException as e:
887
+ logger.warning(f"OpenAI API validation error: {e}")
888
+ return jsonify({
889
+ "error": {
890
+ "message": "Invalid request parameters",
891
+ "type": "invalid_request_error",
892
+ "code": "validation_error"
893
+ }
894
+ }), 400
895
+
896
+ except APIException as e:
897
+ logger.error(f"OpenAI API error: {e}")
898
+ return jsonify({
899
+ "error": {
900
+ "message": "Text-to-speech generation failed",
901
+ "type": "api_error",
902
+ "code": "tts_error"
903
+ }
904
+ }), getattr(e, 'status_code', 500)
905
+
906
+ except NetworkException as e:
907
+ logger.error(f"OpenAI API network error: {e}")
908
+ return jsonify({
909
+ "error": {
910
+ "message": "TTS service is currently unavailable",
911
+ "type": "service_unavailable_error",
912
+ "code": "service_unavailable"
913
+ }
914
+ }), 503
915
+
916
+ except Exception as e:
917
+ logger.error(f"OpenAI API unexpected error: {e}")
918
+ return jsonify({
919
+ "error": {
920
+ "message": "An unexpected error occurred",
921
+ "type": "internal_error",
922
+ "code": "internal_error"
923
+ }
924
+ }), 500
925
+
926
+
927
+
928
+ @app.route('/v1/models', methods=['GET'])
929
+ def openai_models():
930
+ """OpenAI-compatible models endpoint."""
931
+ return jsonify({
932
+ "object": "list",
933
+ "data": [
934
+ {
935
+ "id": "gpt-4o-mini-tts",
936
+ "object": "model",
937
+ "created": 1699564800,
938
+ "owned_by": "ttsfm",
939
+ "permission": [],
940
+ "root": "gpt-4o-mini-tts",
941
+ "parent": None
942
+ }
943
+ ]
944
+ })
945
+
946
+ @app.errorhandler(404)
947
+ def not_found(error):
948
+ """Handle 404 errors."""
949
+ return jsonify({"error": "Endpoint not found"}), 404
950
+
951
+ @app.errorhandler(405)
952
+ def method_not_allowed(error):
953
+ """Handle 405 errors."""
954
+ return jsonify({"error": "Method not allowed"}), 405
955
+
956
+ @app.errorhandler(500)
957
+ def internal_error(error):
958
+ """Handle 500 errors."""
959
+ logger.error(f"Internal server error: {error}")
960
+ return jsonify({"error": "Internal server error"}), 500
961
+
962
+ if __name__ == '__main__':
963
+ logger.info(f"Starting TTSFM web application on {HOST}:{PORT}")
964
+ logger.info("Using openai.fm free TTS service")
965
+ logger.info(f"Debug mode: {DEBUG}")
966
+
967
+ # Log API key protection status
968
+ if REQUIRE_API_KEY:
969
+ if API_KEY:
970
+ logger.info("🔒 API key protection is ENABLED")
971
+ logger.info("All TTS generation requests require a valid API key")
972
+ else:
973
+ logger.warning("⚠️ API key protection is enabled but TTSFM_API_KEY is not set!")
974
+ logger.warning("Please set the TTSFM_API_KEY environment variable")
975
+ else:
976
+ logger.info("🔓 API key protection is DISABLED - all requests are allowed")
977
+ logger.info("Set REQUIRE_API_KEY=true to enable API key protection")
978
+
979
+ try:
980
+ logger.info(f"Starting with {async_mode} async mode")
981
+ socketio.run(app, host=HOST, port=PORT, debug=DEBUG)
982
+ except KeyboardInterrupt:
983
+ logger.info("Application stopped by user")
984
+ except Exception as e:
985
+ logger.error(f"Failed to start application: {e}")
986
+ finally:
987
+ # Clean up TTS client
988
+ tts_client.close()
i18n.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Internationalization (i18n) support for TTSFM Web Application
3
+
4
+ This module provides multi-language support for the Flask web application,
5
+ including language detection, translation management, and template functions.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ from typing import Dict, Any, Optional
11
+ from flask import request, session, current_app
12
+
13
+
14
+ class LanguageManager:
15
+ """Manages language detection, translation loading, and text translation."""
16
+
17
+ def __init__(self, app=None, translations_dir: str = "translations"):
18
+ """
19
+ Initialize the LanguageManager.
20
+
21
+ Args:
22
+ app: Flask application instance
23
+ translations_dir: Directory containing translation files
24
+ """
25
+ self.translations_dir = translations_dir
26
+ self.translations: Dict[str, Dict[str, Any]] = {}
27
+ self.supported_languages = ['en', 'zh']
28
+ self.default_language = 'en'
29
+
30
+ if app is not None:
31
+ self.init_app(app)
32
+
33
+ def init_app(self, app):
34
+ """Initialize the Flask application with i18n support."""
35
+ app.config.setdefault('LANGUAGES', self.supported_languages)
36
+ app.config.setdefault('DEFAULT_LANGUAGE', self.default_language)
37
+
38
+ # Load translations
39
+ self.load_translations()
40
+
41
+ # Register template functions
42
+ app.jinja_env.globals['_'] = self.translate
43
+ app.jinja_env.globals['get_locale'] = self.get_locale
44
+ app.jinja_env.globals['get_supported_languages'] = self.get_supported_languages
45
+
46
+ # Store reference to this instance
47
+ app.language_manager = self
48
+
49
+ def load_translations(self):
50
+ """Load all translation files from the translations directory."""
51
+ translations_path = os.path.join(
52
+ os.path.dirname(__file__),
53
+ self.translations_dir
54
+ )
55
+
56
+ if not os.path.exists(translations_path):
57
+ print(f"Warning: Translations directory not found: {translations_path}")
58
+ return
59
+
60
+ for lang_code in self.supported_languages:
61
+ file_path = os.path.join(translations_path, f"{lang_code}.json")
62
+
63
+ if os.path.exists(file_path):
64
+ try:
65
+ with open(file_path, 'r', encoding='utf-8') as f:
66
+ self.translations[lang_code] = json.load(f)
67
+ print(f"Info: Loaded translations for language: {lang_code}")
68
+ except Exception as e:
69
+ print(f"Error: Failed to load translations for {lang_code}: {e}")
70
+ else:
71
+ print(f"Warning: Translation file not found: {file_path}")
72
+
73
+ def get_locale(self) -> str:
74
+ """
75
+ Get the current locale based on user preference, session, or browser settings.
76
+
77
+ Returns:
78
+ Language code (e.g., 'en', 'zh')
79
+ """
80
+ # 1. Check URL parameter (for language switching)
81
+ if 'lang' in request.args:
82
+ lang = request.args.get('lang')
83
+ if lang in self.supported_languages:
84
+ session['language'] = lang
85
+ return lang
86
+
87
+ # 2. Check session (user's previous choice)
88
+ if 'language' in session:
89
+ lang = session['language']
90
+ if lang in self.supported_languages:
91
+ return lang
92
+
93
+ # 3. Check browser's Accept-Language header
94
+ if request.headers.get('Accept-Language'):
95
+ browser_langs = request.headers.get('Accept-Language').split(',')
96
+ for browser_lang in browser_langs:
97
+ # Extract language code (e.g., 'zh-CN' -> 'zh')
98
+ lang_code = browser_lang.split(';')[0].split('-')[0].strip().lower()
99
+ if lang_code in self.supported_languages:
100
+ session['language'] = lang_code
101
+ return lang_code
102
+
103
+ # 4. Fall back to default language
104
+ return self.default_language
105
+
106
+ def set_locale(self, lang_code: str) -> bool:
107
+ """
108
+ Set the current locale.
109
+
110
+ Args:
111
+ lang_code: Language code to set
112
+
113
+ Returns:
114
+ True if successful, False if language not supported
115
+ """
116
+ if lang_code in self.supported_languages:
117
+ session['language'] = lang_code
118
+ return True
119
+ return False
120
+
121
+ def translate(self, key: str, **kwargs) -> str:
122
+ """
123
+ Translate a text key to the current locale.
124
+
125
+ Args:
126
+ key: Translation key in dot notation (e.g., 'nav.home')
127
+ **kwargs: Variables for string formatting
128
+
129
+ Returns:
130
+ Translated text or the key if translation not found
131
+ """
132
+ locale = self.get_locale()
133
+
134
+ # Get translation for current locale
135
+ translation = self._get_nested_value(
136
+ self.translations.get(locale, {}),
137
+ key
138
+ )
139
+
140
+ # Fall back to default language if not found
141
+ if translation is None and locale != self.default_language:
142
+ translation = self._get_nested_value(
143
+ self.translations.get(self.default_language, {}),
144
+ key
145
+ )
146
+
147
+ # Fall back to key if still not found
148
+ if translation is None:
149
+ translation = key
150
+
151
+ # Format with variables if provided
152
+ if kwargs and isinstance(translation, str):
153
+ try:
154
+ translation = translation.format(**kwargs)
155
+ except (KeyError, ValueError):
156
+ pass # Ignore formatting errors
157
+
158
+ return translation
159
+
160
+ def _get_nested_value(self, data: Dict[str, Any], key: str) -> Optional[str]:
161
+ """
162
+ Get a nested value from a dictionary using dot notation.
163
+
164
+ Args:
165
+ data: Dictionary to search in
166
+ key: Dot-separated key (e.g., 'nav.home')
167
+
168
+ Returns:
169
+ Value if found, None otherwise
170
+ """
171
+ keys = key.split('.')
172
+ current = data
173
+
174
+ for k in keys:
175
+ if isinstance(current, dict) and k in current:
176
+ current = current[k]
177
+ else:
178
+ return None
179
+
180
+ return current if isinstance(current, str) else None
181
+
182
+ def get_supported_languages(self) -> Dict[str, str]:
183
+ """
184
+ Get a dictionary of supported languages with their display names.
185
+
186
+ Returns:
187
+ Dictionary mapping language codes to display names
188
+ """
189
+ return {
190
+ 'en': 'English',
191
+ 'zh': '中文'
192
+ }
193
+
194
+ def get_language_info(self, lang_code: str) -> Dict[str, str]:
195
+ """
196
+ Get information about a specific language.
197
+
198
+ Args:
199
+ lang_code: Language code
200
+
201
+ Returns:
202
+ Dictionary with language information
203
+ """
204
+ language_names = {
205
+ 'en': {'name': 'English', 'native': 'English'},
206
+ 'zh': {'name': 'Chinese', 'native': '中文'}
207
+ }
208
+
209
+ return language_names.get(lang_code, {
210
+ 'name': lang_code.upper(),
211
+ 'native': lang_code.upper()
212
+ })
213
+
214
+
215
+ # Global instance
216
+ language_manager = LanguageManager()
217
+
218
+
219
+ def init_i18n(app):
220
+ """Initialize i18n support for the Flask application."""
221
+ language_manager.init_app(app)
222
+ return language_manager
223
+
224
+
225
+ # Template helper functions
226
+ def _(key: str, **kwargs) -> str:
227
+ """Shorthand translation function for use in templates and code."""
228
+ return language_manager.translate(key, **kwargs)
229
+
230
+
231
+ def get_locale() -> str:
232
+ """Get the current locale."""
233
+ return language_manager.get_locale()
234
+
235
+
236
+ def set_locale(lang_code: str) -> bool:
237
+ """Set the current locale."""
238
+ return language_manager.set_locale(lang_code)
requirements.txt CHANGED
@@ -1,4 +1,20 @@
 
 
 
 
 
 
1
  # Core dependencies for the TTSFM package
2
  requests>=2.25.0
3
  aiohttp>=3.8.0
4
- fake-useragent>=1.4.0
 
 
 
 
 
 
 
 
 
 
 
1
+ # Web application dependencies
2
+ flask>=2.0.0
3
+ flask-cors>=3.0.10
4
+ flask-socketio>=5.3.0
5
+ python-socketio>=5.10.0
6
+ eventlet>=0.33.3
7
  # Core dependencies for the TTSFM package
8
  requests>=2.25.0
9
  aiohttp>=3.8.0
10
+ fake-useragent>=1.4.0
11
+ waitress>=3.0.0
12
+ python-dotenv>=1.0.0
13
+
14
+ # Audio processing (optional, for combining audio files)
15
+ # If not installed, will fall back to simple concatenation for WAV files
16
+ pydub>=0.25.0
17
+
18
+ # TTSFM package (install from local directory or PyPI)
19
+ # For local development: pip install -e ../
20
+ # For Docker/production: installed via pyproject.toml[web] dependencies
run.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Run script for TTSFM web application with proper eventlet initialization
4
+ """
5
+
6
+ # MUST be the first imports for eventlet to work properly
7
+ import eventlet
8
+ eventlet.monkey_patch()
9
+
10
+ # Now import the app
11
+ from app import app, socketio, HOST, PORT, DEBUG
12
+
13
+ if __name__ == '__main__':
14
+ print(f"Starting TTSFM with WebSocket support on {HOST}:{PORT}")
15
+ socketio.run(app, host=HOST, port=PORT, debug=DEBUG, allow_unsafe_werkzeug=True)
static/css/style.css ADDED
@@ -0,0 +1,1399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* TTSFM Web Application Custom Styles */
2
+
3
+ :root {
4
+ /* Clean Color Palette */
5
+ --primary-color: #4f46e5;
6
+ --primary-dark: #3730a3;
7
+ --primary-light: #6366f1;
8
+ --secondary-color: #6b7280;
9
+ --secondary-dark: #4b5563;
10
+ --accent-color: #059669;
11
+ --accent-dark: #047857;
12
+
13
+ /* Status Colors */
14
+ --success-color: #059669;
15
+ --warning-color: #d97706;
16
+ --danger-color: #dc2626;
17
+ --info-color: #2563eb;
18
+
19
+ /* Clean Neutral Colors */
20
+ --light-color: #ffffff;
21
+ --light-gray: #f9fafb;
22
+ --medium-gray: #6b7280;
23
+ --dark-color: #111827;
24
+ --text-color: #374151;
25
+ --text-muted: #6b7280;
26
+
27
+ /* Design System */
28
+ --border-radius: 0.75rem;
29
+ --border-radius-sm: 0.5rem;
30
+ --border-radius-lg: 1rem;
31
+ --box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
32
+ --box-shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
33
+ --box-shadow-xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
34
+ --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
35
+ --transition-fast: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
36
+
37
+ /* Gradients */
38
+ --gradient-primary: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%);
39
+ --gradient-secondary: linear-gradient(135deg, var(--secondary-color) 0%, var(--secondary-dark) 100%);
40
+ --gradient-accent: linear-gradient(135deg, var(--accent-color) 0%, var(--accent-dark) 100%);
41
+ --gradient-hero: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 50%, var(--accent-color) 100%);
42
+ }
43
+
44
+ /* Global Styles */
45
+ body {
46
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
47
+ line-height: 1.6;
48
+ color: var(--text-color);
49
+ background-color: #ffffff;
50
+ font-weight: 400;
51
+ -webkit-font-smoothing: antialiased;
52
+ -moz-osx-font-smoothing: grayscale;
53
+ }
54
+
55
+ /* Enhanced Typography */
56
+ h1, h2, h3, h4, h5, h6 {
57
+ font-weight: 700;
58
+ line-height: 1.3;
59
+ color: var(--dark-color);
60
+ letter-spacing: -0.025em;
61
+ }
62
+
63
+ .display-1, .display-2, .display-3, .display-4 {
64
+ font-weight: 800;
65
+ letter-spacing: -0.05em;
66
+ }
67
+
68
+ .lead {
69
+ font-size: 1.125rem;
70
+ font-weight: 400;
71
+ color: var(--text-muted);
72
+ line-height: 1.8;
73
+ }
74
+
75
+ /* Simplified Button Styles */
76
+ .btn {
77
+ font-weight: 600;
78
+ border-radius: 12px;
79
+ transition: all 0.3s ease;
80
+ letter-spacing: 0.025em;
81
+ border: none;
82
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
83
+ }
84
+
85
+ .btn-primary {
86
+ background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%);
87
+ color: white;
88
+ }
89
+
90
+ .btn-primary:hover {
91
+ background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary-color) 100%);
92
+ color: white;
93
+ transform: translateY(-1px);
94
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
95
+ }
96
+
97
+ .btn-outline-primary {
98
+ border: 2px solid var(--primary-color);
99
+ color: var(--primary-color);
100
+ background: transparent;
101
+ box-shadow: none;
102
+ }
103
+
104
+ .btn-outline-primary:hover {
105
+ background: var(--primary-color);
106
+ border-color: var(--primary-color);
107
+ color: white;
108
+ transform: translateY(-1px);
109
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
110
+ }
111
+
112
+ .btn-lg {
113
+ padding: 0.875rem 2rem;
114
+ font-size: 1.125rem;
115
+ border-radius: var(--border-radius);
116
+ }
117
+
118
+ .btn-sm {
119
+ padding: 0.5rem 1rem;
120
+ font-size: 0.875rem;
121
+ border-radius: var(--border-radius-sm);
122
+ }
123
+
124
+ /* Clean Card Styles */
125
+ .card {
126
+ border: 1px solid #e5e7eb;
127
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
128
+ transition: all 0.3s ease;
129
+ border-radius: 16px;
130
+ background: white;
131
+ }
132
+
133
+ .card:hover {
134
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
135
+ border-color: var(--primary-light);
136
+ transform: translateY(-2px);
137
+ }
138
+
139
+ .card-body {
140
+ padding: 2rem;
141
+ }
142
+
143
+ /* Clean Hero Section */
144
+ .hero-section {
145
+ background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
146
+ color: var(--text-color);
147
+ padding: 5rem 0;
148
+ min-height: 75vh;
149
+ display: flex;
150
+ align-items: center;
151
+ border-bottom: 1px solid #e5e7eb;
152
+ }
153
+
154
+ .min-vh-75 {
155
+ min-height: 75vh;
156
+ }
157
+
158
+ /* Status Indicators */
159
+ .status-indicator {
160
+ display: inline-block;
161
+ width: 8px;
162
+ height: 8px;
163
+ border-radius: 50%;
164
+ background-color: #6c757d;
165
+ }
166
+
167
+ .status-online {
168
+ background-color: #28a745;
169
+ }
170
+
171
+ .status-offline {
172
+ background-color: #dc3545;
173
+ }
174
+
175
+ /* Footer */
176
+ .footer {
177
+ margin-top: auto;
178
+ }
179
+
180
+ /* Clean Code Blocks */
181
+ pre {
182
+ background-color: #f8fafc !important;
183
+ border: 1px solid #e5e7eb;
184
+ border-radius: 8px;
185
+ font-size: 0.875rem;
186
+ }
187
+
188
+ code {
189
+ color: #374151;
190
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
191
+ }
192
+
193
+ /* Enhanced Form Styles */
194
+ .form-control, .form-select {
195
+ border-radius: 12px;
196
+ border: 2px solid #e5e7eb;
197
+ transition: var(--transition);
198
+ padding: 1rem 1.25rem;
199
+ font-size: 1rem;
200
+ background-color: #ffffff;
201
+ color: var(--text-color);
202
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
203
+ }
204
+
205
+ .form-control:focus, .form-select:focus {
206
+ border-color: var(--primary-color);
207
+ box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.1);
208
+ outline: none;
209
+ background-color: #ffffff;
210
+ transform: translateY(-1px);
211
+ }
212
+
213
+ .form-control:hover, .form-select:hover {
214
+ border-color: var(--primary-light);
215
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
216
+ }
217
+
218
+ .form-label {
219
+ font-weight: 600;
220
+ color: var(--dark-color);
221
+ margin-bottom: 0.75rem;
222
+ font-size: 0.95rem;
223
+ }
224
+
225
+ .form-text {
226
+ color: var(--text-muted);
227
+ font-size: 0.875rem;
228
+ margin-top: 0.5rem;
229
+ }
230
+
231
+ .form-check-input {
232
+ border-radius: var(--border-radius-sm);
233
+ border: 2px solid #e2e8f0;
234
+ width: 1.25rem;
235
+ height: 1.25rem;
236
+ }
237
+
238
+ .form-check-input:checked {
239
+ background-color: var(--primary-color);
240
+ border-color: var(--primary-color);
241
+ }
242
+
243
+ .form-check-input:focus {
244
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
245
+ }
246
+
247
+ .form-check-label {
248
+ color: var(--text-color);
249
+ font-weight: 500;
250
+ margin-left: 0.5rem;
251
+ }
252
+
253
+ /* Enhanced Status Indicators */
254
+ .status-indicator {
255
+ display: inline-block;
256
+ width: 12px;
257
+ height: 12px;
258
+ border-radius: 50%;
259
+ margin-right: 8px;
260
+ position: relative;
261
+ animation: statusPulse 2s infinite;
262
+ }
263
+
264
+ .status-indicator::before {
265
+ content: '';
266
+ position: absolute;
267
+ top: -2px;
268
+ left: -2px;
269
+ right: -2px;
270
+ bottom: -2px;
271
+ border-radius: 50%;
272
+ opacity: 0.3;
273
+ animation: statusRing 2s infinite;
274
+ }
275
+
276
+ .status-online {
277
+ background-color: var(--success-color);
278
+ box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
279
+ }
280
+
281
+ .status-online::before {
282
+ background-color: var(--success-color);
283
+ }
284
+
285
+ .status-offline {
286
+ background-color: var(--danger-color);
287
+ box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
288
+ }
289
+
290
+ .status-offline::before {
291
+ background-color: var(--danger-color);
292
+ }
293
+
294
+ @keyframes statusPulse {
295
+ 0%, 100% { opacity: 1; }
296
+ 50% { opacity: 0.7; }
297
+ }
298
+
299
+ @keyframes statusRing {
300
+ 0% { transform: scale(0.8); opacity: 0.8; }
301
+ 100% { transform: scale(1.4); opacity: 0; }
302
+ }
303
+
304
+ /* Enhanced Audio Player */
305
+ .audio-player {
306
+ width: 100%;
307
+ margin-top: 1rem;
308
+ border-radius: var(--border-radius);
309
+ box-shadow: var(--box-shadow);
310
+ background: var(--light-color);
311
+ padding: 0.5rem;
312
+ }
313
+
314
+ .audio-player::-webkit-media-controls-panel {
315
+ background-color: var(--light-color);
316
+ border-radius: var(--border-radius-sm);
317
+ }
318
+
319
+ /* Enhanced Sections */
320
+ .features-section {
321
+ padding: 6rem 0;
322
+ background: linear-gradient(180deg, #ffffff 0%, var(--light-color) 100%);
323
+ }
324
+
325
+ .stats-section {
326
+ padding: 4rem 0;
327
+ background: var(--gradient-primary);
328
+ color: white;
329
+ position: relative;
330
+ overflow: hidden;
331
+ }
332
+
333
+ .stats-section::before {
334
+ content: '';
335
+ position: absolute;
336
+ top: 0;
337
+ left: 0;
338
+ right: 0;
339
+ bottom: 0;
340
+ background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="stats-pattern" width="40" height="40" patternUnits="userSpaceOnUse"><circle cx="20" cy="20" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23stats-pattern)"/></svg>');
341
+ }
342
+
343
+ .stat-card {
344
+ text-align: center;
345
+ padding: 2rem 1rem;
346
+ background: rgba(255, 255, 255, 0.1);
347
+ border-radius: var(--border-radius);
348
+ backdrop-filter: blur(10px);
349
+ border: 1px solid rgba(255, 255, 255, 0.2);
350
+ transition: var(--transition);
351
+ }
352
+
353
+ .stat-card:hover {
354
+ transform: translateY(-5px);
355
+ background: rgba(255, 255, 255, 0.15);
356
+ }
357
+
358
+ .stat-icon {
359
+ font-size: 2.5rem;
360
+ margin-bottom: 1rem;
361
+ color: rgba(255, 255, 255, 0.9);
362
+ }
363
+
364
+ .stat-number {
365
+ font-size: 3rem;
366
+ font-weight: 800;
367
+ color: white;
368
+ margin-bottom: 0.5rem;
369
+ display: block;
370
+ }
371
+
372
+ .stat-label {
373
+ color: rgba(255, 255, 255, 0.9);
374
+ font-weight: 500;
375
+ font-size: 0.95rem;
376
+ }
377
+
378
+ .quick-start-section {
379
+ padding: 6rem 0;
380
+ }
381
+
382
+ .use-cases-section {
383
+ padding: 6rem 0;
384
+ background: var(--light-color);
385
+ }
386
+
387
+ .tech-specs-section {
388
+ padding: 6rem 0;
389
+ }
390
+
391
+ .faq-section {
392
+ padding: 6rem 0;
393
+ background: var(--light-color);
394
+ }
395
+
396
+ .final-cta-section {
397
+ padding: 6rem 0;
398
+ background: var(--gradient-hero);
399
+ color: white;
400
+ position: relative;
401
+ overflow: hidden;
402
+ }
403
+
404
+ .cta-background-animation {
405
+ position: absolute;
406
+ top: 0;
407
+ left: 0;
408
+ right: 0;
409
+ bottom: 0;
410
+ background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.05) 50%, transparent 70%);
411
+ animation: shimmer 4s ease-in-out infinite;
412
+ }
413
+
414
+ .section-badge {
415
+ display: inline-block;
416
+ background: var(--gradient-primary);
417
+ color: white;
418
+ padding: 0.5rem 1.5rem;
419
+ border-radius: 2rem;
420
+ font-size: 0.875rem;
421
+ font-weight: 600;
422
+ margin-bottom: 1.5rem;
423
+ box-shadow: 0 4px 14px 0 rgba(99, 102, 241, 0.3);
424
+ }
425
+
426
+ /* Enhanced Loading States */
427
+ .loading-spinner {
428
+ display: none;
429
+ }
430
+
431
+ .loading .loading-spinner {
432
+ display: inline-block;
433
+ }
434
+
435
+ .loading .btn-text {
436
+ display: none;
437
+ }
438
+
439
+ .loading {
440
+ position: relative;
441
+ overflow: hidden;
442
+ }
443
+
444
+ .loading::after {
445
+ content: '';
446
+ position: absolute;
447
+ top: 0;
448
+ left: -100%;
449
+ width: 100%;
450
+ height: 100%;
451
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
452
+ animation: loading-shimmer 1.5s infinite;
453
+ }
454
+
455
+ @keyframes loading-shimmer {
456
+ 0% { left: -100%; }
457
+ 100% { left: 100%; }
458
+ }
459
+
460
+ /* Enhanced Code Blocks */
461
+ .code-card {
462
+ background: white;
463
+ border-radius: var(--border-radius);
464
+ box-shadow: var(--box-shadow);
465
+ overflow: hidden;
466
+ border: 1px solid #e2e8f0;
467
+ transition: var(--transition);
468
+ }
469
+
470
+ .code-card:hover {
471
+ transform: translateY(-2px);
472
+ box-shadow: var(--box-shadow-lg);
473
+ }
474
+
475
+ .code-header {
476
+ background: var(--light-gray);
477
+ padding: 1rem 1.5rem;
478
+ border-bottom: 1px solid #e2e8f0;
479
+ display: flex;
480
+ justify-content: between;
481
+ align-items: center;
482
+ }
483
+
484
+ .code-header h4 {
485
+ margin: 0;
486
+ font-size: 1.1rem;
487
+ color: var(--dark-color);
488
+ }
489
+
490
+ .code-content {
491
+ padding: 1.5rem;
492
+ background: #f8fafc;
493
+ margin: 0;
494
+ overflow-x: auto;
495
+ }
496
+
497
+ .code-content code {
498
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
499
+ font-size: 0.9rem;
500
+ line-height: 1.6;
501
+ color: var(--text-color);
502
+ }
503
+
504
+ .code-footer {
505
+ padding: 1rem 1.5rem;
506
+ background: white;
507
+ border-top: 1px solid #e2e8f0;
508
+ }
509
+
510
+ .copy-btn {
511
+ font-size: 0.8rem;
512
+ padding: 0.25rem 0.75rem;
513
+ }
514
+
515
+ /* Enhanced Use Case Cards */
516
+ .use-case-card {
517
+ background: white;
518
+ border-radius: var(--border-radius);
519
+ padding: 2rem;
520
+ box-shadow: var(--box-shadow);
521
+ transition: var(--transition);
522
+ border: 1px solid #e2e8f0;
523
+ height: 100%;
524
+ text-align: center;
525
+ }
526
+
527
+ .use-case-card:hover {
528
+ transform: translateY(-4px);
529
+ box-shadow: var(--box-shadow-lg);
530
+ border-color: rgba(99, 102, 241, 0.2);
531
+ }
532
+
533
+ .use-case-icon {
534
+ width: 4rem;
535
+ height: 4rem;
536
+ background: var(--gradient-primary);
537
+ border-radius: 50%;
538
+ display: flex;
539
+ align-items: center;
540
+ justify-content: center;
541
+ font-size: 1.5rem;
542
+ color: white;
543
+ margin: 0 auto 1.5rem;
544
+ box-shadow: 0 4px 14px 0 rgba(99, 102, 241, 0.3);
545
+ }
546
+
547
+ .use-case-title {
548
+ font-size: 1.25rem;
549
+ font-weight: 700;
550
+ color: var(--dark-color);
551
+ margin-bottom: 1rem;
552
+ }
553
+
554
+ .use-case-description {
555
+ color: var(--text-muted);
556
+ margin-bottom: 1.5rem;
557
+ line-height: 1.7;
558
+ }
559
+
560
+ .use-case-examples {
561
+ display: flex;
562
+ flex-wrap: wrap;
563
+ gap: 0.5rem;
564
+ justify-content: center;
565
+ }
566
+
567
+ .use-case-examples .badge {
568
+ font-size: 0.75rem;
569
+ padding: 0.4rem 0.8rem;
570
+ border-radius: 1rem;
571
+ background: var(--light-gray);
572
+ color: var(--text-color);
573
+ border: 1px solid #e2e8f0;
574
+ }
575
+
576
+ /* Enhanced Tech Spec Cards */
577
+ .tech-spec-card {
578
+ background: white;
579
+ border-radius: var(--border-radius);
580
+ padding: 2rem;
581
+ box-shadow: var(--box-shadow);
582
+ transition: var(--transition);
583
+ border: 1px solid #e2e8f0;
584
+ height: 100%;
585
+ }
586
+
587
+ .tech-spec-card:hover {
588
+ transform: translateY(-2px);
589
+ box-shadow: var(--box-shadow-lg);
590
+ }
591
+
592
+ .tech-spec-icon {
593
+ width: 3rem;
594
+ height: 3rem;
595
+ background: var(--gradient-accent);
596
+ border-radius: var(--border-radius-sm);
597
+ display: flex;
598
+ align-items: center;
599
+ justify-content: center;
600
+ font-size: 1.25rem;
601
+ color: white;
602
+ margin: 0 auto 1rem;
603
+ }
604
+
605
+ .tech-spec-card h4, .tech-spec-card h5 {
606
+ color: var(--dark-color);
607
+ margin-bottom: 1.5rem;
608
+ }
609
+
610
+ .tech-spec-card ul {
611
+ list-style: none;
612
+ padding: 0;
613
+ }
614
+
615
+ .tech-spec-card li {
616
+ padding: 0.5rem 0;
617
+ color: var(--text-color);
618
+ border-bottom: 1px solid #f1f5f9;
619
+ }
620
+
621
+ .tech-spec-card li:last-child {
622
+ border-bottom: none;
623
+ }
624
+
625
+ /* Enhanced Validation Styles */
626
+ .badge {
627
+ font-size: 0.75em;
628
+ padding: 0.4em 0.8em;
629
+ border-radius: 1rem;
630
+ font-weight: 600;
631
+ letter-spacing: 0.025em;
632
+ }
633
+
634
+ .validation-result {
635
+ animation: slideDown 0.3s ease;
636
+ }
637
+
638
+ @keyframes slideDown {
639
+ from {
640
+ opacity: 0;
641
+ transform: translateY(-10px);
642
+ }
643
+ to {
644
+ opacity: 1;
645
+ transform: translateY(0);
646
+ }
647
+ }
648
+
649
+ /* Enhanced Alert Styles */
650
+ .alert {
651
+ border-radius: var(--border-radius);
652
+ border: none;
653
+ box-shadow: var(--box-shadow);
654
+ padding: 1rem 1.5rem;
655
+ }
656
+
657
+ .alert-success {
658
+ background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(16, 185, 129, 0.05) 100%);
659
+ color: #065f46;
660
+ border-left: 4px solid var(--success-color);
661
+ }
662
+
663
+ .alert-warning {
664
+ background: linear-gradient(135deg, rgba(245, 158, 11, 0.1) 0%, rgba(245, 158, 11, 0.05) 100%);
665
+ color: #92400e;
666
+ border-left: 4px solid var(--warning-color);
667
+ }
668
+
669
+ .alert-danger {
670
+ background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(239, 68, 68, 0.05) 100%);
671
+ color: #991b1b;
672
+ border-left: 4px solid var(--danger-color);
673
+ }
674
+
675
+ .alert-info {
676
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(59, 130, 246, 0.05) 100%);
677
+ color: #1e40af;
678
+ border-left: 4px solid var(--info-color);
679
+ }
680
+
681
+ /* Enhanced Accordion */
682
+ .accordion-item {
683
+ border: none;
684
+ margin-bottom: 1rem;
685
+ border-radius: var(--border-radius) !important;
686
+ box-shadow: var(--box-shadow);
687
+ overflow: hidden;
688
+ }
689
+
690
+ .accordion-button {
691
+ background: white;
692
+ border: none;
693
+ padding: 1.5rem;
694
+ font-weight: 600;
695
+ color: var(--dark-color);
696
+ border-radius: var(--border-radius) !important;
697
+ }
698
+
699
+ .accordion-button:not(.collapsed) {
700
+ background: var(--light-gray);
701
+ color: var(--primary-color);
702
+ box-shadow: none;
703
+ }
704
+
705
+ .accordion-button:focus {
706
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
707
+ border-color: transparent;
708
+ }
709
+
710
+ .accordion-body {
711
+ padding: 1.5rem;
712
+ background: white;
713
+ color: var(--text-color);
714
+ line-height: 1.7;
715
+ }
716
+
717
+ /* Enhanced CTA Buttons */
718
+ .cta-btn-primary, .cta-btn-secondary {
719
+ position: relative;
720
+ overflow: hidden;
721
+ backdrop-filter: blur(10px);
722
+ border-radius: var(--border-radius);
723
+ }
724
+
725
+ .cta-btn-primary small, .cta-btn-secondary small {
726
+ font-size: 0.75rem;
727
+ opacity: 0.9;
728
+ font-weight: 400;
729
+ }
730
+
731
+ .cta-content {
732
+ position: relative;
733
+ z-index: 2;
734
+ }
735
+
736
+ .cta-buttons {
737
+ margin: 2rem 0;
738
+ }
739
+
740
+ .cta-stats {
741
+ margin-top: 3rem;
742
+ }
743
+
744
+ .cta-stat h4 {
745
+ font-size: 2rem;
746
+ font-weight: 800;
747
+ margin-bottom: 0.25rem;
748
+ }
749
+
750
+ .cta-stat small {
751
+ font-size: 0.9rem;
752
+ opacity: 0.9;
753
+ }
754
+
755
+ /* Enhanced Quick Start */
756
+ .quick-start-cta {
757
+ background: white;
758
+ border-radius: var(--border-radius-lg);
759
+ padding: 3rem;
760
+ box-shadow: var(--box-shadow-lg);
761
+ text-align: center;
762
+ border: 1px solid #e2e8f0;
763
+ }
764
+
765
+ .quick-start-cta h4 {
766
+ color: var(--dark-color);
767
+ margin-bottom: 1.5rem;
768
+ }
769
+
770
+ /* Enhanced Batch Processing */
771
+ .batch-chunk-card {
772
+ transition: var(--transition);
773
+ border: 1px solid #e2e8f0;
774
+ border-radius: var(--border-radius);
775
+ overflow: hidden;
776
+ }
777
+
778
+ .batch-chunk-card:hover {
779
+ transform: translateY(-2px);
780
+ box-shadow: var(--box-shadow-lg);
781
+ border-color: rgba(99, 102, 241, 0.2);
782
+ }
783
+
784
+ .batch-chunk-card .card-body {
785
+ padding: 1.5rem;
786
+ }
787
+
788
+ .batch-chunk-card .card-title {
789
+ font-size: 1rem;
790
+ font-weight: 600;
791
+ color: var(--dark-color);
792
+ }
793
+
794
+ .batch-chunk-card .card-text {
795
+ color: var(--text-muted);
796
+ line-height: 1.6;
797
+ }
798
+
799
+ .download-chunk {
800
+ transition: var(--transition-fast);
801
+ }
802
+
803
+ .download-chunk:hover {
804
+ transform: scale(1.1);
805
+ }
806
+
807
+ /* Enhanced Navigation */
808
+ .navbar {
809
+ backdrop-filter: blur(10px);
810
+ background: rgba(255, 255, 255, 0.95) !important;
811
+ border-bottom: 1px solid rgba(226, 232, 240, 0.8);
812
+ box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
813
+ }
814
+
815
+ .navbar-brand {
816
+ font-weight: 800;
817
+ font-size: 1.5rem;
818
+ color: var(--primary-color) !important;
819
+ transition: var(--transition);
820
+ }
821
+
822
+ .navbar-brand:hover {
823
+ transform: scale(1.05);
824
+ }
825
+
826
+ .navbar-nav .nav-link {
827
+ font-weight: 500;
828
+ transition: var(--transition);
829
+ color: var(--text-color) !important;
830
+ position: relative;
831
+ padding: 0.75rem 1rem !important;
832
+ }
833
+
834
+ .navbar-nav .nav-link::after {
835
+ content: '';
836
+ position: absolute;
837
+ bottom: 0;
838
+ left: 50%;
839
+ width: 0;
840
+ height: 2px;
841
+ background: var(--gradient-primary);
842
+ transition: var(--transition);
843
+ transform: translateX(-50%);
844
+ }
845
+
846
+ .navbar-nav .nav-link:hover::after {
847
+ width: 80%;
848
+ }
849
+
850
+ .navbar-nav .nav-link:hover {
851
+ color: var(--primary-color) !important;
852
+ }
853
+
854
+ .navbar-text {
855
+ color: var(--text-muted) !important;
856
+ font-weight: 500;
857
+ }
858
+
859
+ /* Enhanced Footer */
860
+ .footer {
861
+ background: linear-gradient(135deg, var(--dark-color) 0%, #2d3748 100%);
862
+ color: white;
863
+ padding: 3rem 0 2rem;
864
+ margin-top: 6rem;
865
+ position: relative;
866
+ overflow: hidden;
867
+ }
868
+
869
+ .footer::before {
870
+ content: '';
871
+ position: absolute;
872
+ top: 0;
873
+ left: 0;
874
+ right: 0;
875
+ bottom: 0;
876
+ background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="footer-pattern" width="20" height="20" patternUnits="userSpaceOnUse"><circle cx="10" cy="10" r="0.5" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23footer-pattern)"/></svg>');
877
+ }
878
+
879
+ .footer h5 {
880
+ color: white;
881
+ font-weight: 700;
882
+ margin-bottom: 1rem;
883
+ }
884
+
885
+ .footer p, .footer a {
886
+ color: rgba(255, 255, 255, 0.8);
887
+ transition: var(--transition);
888
+ }
889
+
890
+ .footer a:hover {
891
+ color: white;
892
+ text-decoration: none;
893
+ }
894
+
895
+ /* Enhanced Responsive Design */
896
+ @media (max-width: 1200px) {
897
+ .hero-section {
898
+ padding: 4rem 0;
899
+ }
900
+
901
+ .floating-icon-container {
902
+ width: 250px;
903
+ height: 250px;
904
+ }
905
+
906
+ .floating-icon {
907
+ width: 50px;
908
+ height: 50px;
909
+ font-size: 1.25rem;
910
+ }
911
+
912
+ .hero-main-icon {
913
+ width: 100px;
914
+ height: 100px;
915
+ font-size: 2.5rem;
916
+ }
917
+ }
918
+
919
+ @media (max-width: 992px) {
920
+ .hero-section {
921
+ padding: 3rem 0;
922
+ min-height: auto;
923
+ }
924
+
925
+ .display-3 {
926
+ font-size: 2.5rem;
927
+ }
928
+
929
+ .features-section, .stats-section, .quick-start-section,
930
+ .use-cases-section, .tech-specs-section, .faq-section,
931
+ .final-cta-section {
932
+ padding: 4rem 0;
933
+ }
934
+
935
+ .floating-icon-container {
936
+ display: none;
937
+ }
938
+
939
+ .hero-visual {
940
+ margin-top: 2rem;
941
+ }
942
+ }
943
+
944
+ @media (max-width: 768px) {
945
+ .hero-section {
946
+ padding: 2rem 0;
947
+ text-align: center;
948
+ }
949
+
950
+ .display-3 {
951
+ font-size: 2rem;
952
+ }
953
+
954
+ .lead {
955
+ font-size: 1rem;
956
+ }
957
+
958
+ .btn-lg {
959
+ padding: 0.75rem 1.5rem;
960
+ font-size: 1rem;
961
+ width: 100%;
962
+ margin-bottom: 1rem;
963
+ }
964
+
965
+ .hero-stats .col-4 {
966
+ margin-bottom: 1rem;
967
+ }
968
+
969
+ .stat-item h3 {
970
+ font-size: 2rem;
971
+ }
972
+
973
+ .features-section, .stats-section, .quick-start-section,
974
+ .use-cases-section, .tech-specs-section, .faq-section,
975
+ .final-cta-section {
976
+ padding: 3rem 0;
977
+ }
978
+
979
+ .feature-card-enhanced, .use-case-card, .tech-spec-card {
980
+ margin-bottom: 2rem;
981
+ }
982
+
983
+ .code-card {
984
+ margin-bottom: 1.5rem;
985
+ }
986
+
987
+ .code-header {
988
+ flex-direction: column;
989
+ gap: 1rem;
990
+ text-align: center;
991
+ }
992
+
993
+ .quick-start-cta {
994
+ padding: 2rem 1rem;
995
+ }
996
+
997
+ .cta-buttons .btn {
998
+ width: 100%;
999
+ margin-bottom: 1rem;
1000
+ }
1001
+
1002
+ .navbar-nav {
1003
+ text-align: center;
1004
+ padding: 1rem 0;
1005
+ }
1006
+
1007
+ .toc {
1008
+ position: static;
1009
+ margin-bottom: 2rem;
1010
+ max-height: none;
1011
+ }
1012
+ }
1013
+
1014
+ @media (max-width: 576px) {
1015
+ .container {
1016
+ padding-left: 1rem;
1017
+ padding-right: 1rem;
1018
+ }
1019
+
1020
+ .hero-section {
1021
+ padding: 1.5rem 0;
1022
+ }
1023
+
1024
+ .display-3 {
1025
+ font-size: 1.75rem;
1026
+ }
1027
+
1028
+ .card-body {
1029
+ padding: 1.5rem;
1030
+ }
1031
+
1032
+ .feature-card-enhanced, .use-case-card, .tech-spec-card {
1033
+ padding: 1.5rem;
1034
+ }
1035
+
1036
+ .stat-number {
1037
+ font-size: 2.5rem;
1038
+ }
1039
+
1040
+ .hero-main-icon {
1041
+ width: 80px;
1042
+ height: 80px;
1043
+ font-size: 2rem;
1044
+ }
1045
+
1046
+ .pulse-ring {
1047
+ width: 100px;
1048
+ height: 100px;
1049
+ }
1050
+ }
1051
+
1052
+ /* Enhanced Accessibility */
1053
+ .btn:focus,
1054
+ .form-control:focus,
1055
+ .form-select:focus,
1056
+ .form-check-input:focus {
1057
+ outline: 3px solid rgba(99, 102, 241, 0.3);
1058
+ outline-offset: 2px;
1059
+ }
1060
+
1061
+ .btn:focus-visible,
1062
+ .form-control:focus-visible,
1063
+ .form-select:focus-visible {
1064
+ outline: 3px solid var(--primary-color);
1065
+ outline-offset: 2px;
1066
+ }
1067
+
1068
+ /* Skip to content link for screen readers */
1069
+ .skip-link {
1070
+ position: absolute;
1071
+ top: -40px;
1072
+ left: 6px;
1073
+ background: var(--primary-color);
1074
+ color: white;
1075
+ padding: 8px;
1076
+ text-decoration: none;
1077
+ border-radius: 4px;
1078
+ z-index: 1000;
1079
+ }
1080
+
1081
+ .skip-link:focus {
1082
+ top: 6px;
1083
+ }
1084
+
1085
+ /* Enhanced Animation Classes */
1086
+ .fade-in {
1087
+ animation: fadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1);
1088
+ }
1089
+
1090
+ @keyframes fadeIn {
1091
+ from {
1092
+ opacity: 0;
1093
+ transform: translateY(10px);
1094
+ }
1095
+ to {
1096
+ opacity: 1;
1097
+ transform: translateY(0);
1098
+ }
1099
+ }
1100
+
1101
+ .slide-up {
1102
+ animation: slideUp 0.6s cubic-bezier(0.4, 0, 0.2, 1);
1103
+ }
1104
+
1105
+ @keyframes slideUp {
1106
+ from {
1107
+ opacity: 0;
1108
+ transform: translateY(30px);
1109
+ }
1110
+ to {
1111
+ opacity: 1;
1112
+ transform: translateY(0);
1113
+ }
1114
+ }
1115
+
1116
+ .scale-in {
1117
+ animation: scaleIn 0.5s cubic-bezier(0.4, 0, 0.2, 1);
1118
+ }
1119
+
1120
+ @keyframes scaleIn {
1121
+ from {
1122
+ opacity: 0;
1123
+ transform: scale(0.9);
1124
+ }
1125
+ to {
1126
+ opacity: 1;
1127
+ transform: scale(1);
1128
+ }
1129
+ }
1130
+
1131
+ /* Enhanced Utility Classes */
1132
+ .text-gradient {
1133
+ background: var(--gradient-primary);
1134
+ -webkit-background-clip: text;
1135
+ -webkit-text-fill-color: transparent;
1136
+ background-clip: text;
1137
+ }
1138
+
1139
+ .text-gradient-secondary {
1140
+ background: var(--gradient-secondary);
1141
+ -webkit-background-clip: text;
1142
+ -webkit-text-fill-color: transparent;
1143
+ background-clip: text;
1144
+ }
1145
+
1146
+ .shadow-custom {
1147
+ box-shadow: var(--box-shadow);
1148
+ }
1149
+
1150
+ .shadow-lg-custom {
1151
+ box-shadow: var(--box-shadow-lg);
1152
+ }
1153
+
1154
+ .shadow-xl-custom {
1155
+ box-shadow: var(--box-shadow-xl);
1156
+ }
1157
+
1158
+ .border-radius-custom {
1159
+ border-radius: var(--border-radius);
1160
+ }
1161
+
1162
+ .bg-gradient-primary {
1163
+ background: var(--gradient-primary);
1164
+ }
1165
+
1166
+ .bg-gradient-secondary {
1167
+ background: var(--gradient-secondary);
1168
+ }
1169
+
1170
+ .bg-gradient-accent {
1171
+ background: var(--gradient-accent);
1172
+ }
1173
+
1174
+ /* Enhanced Progress Indicators */
1175
+ .progress-custom {
1176
+ height: 10px;
1177
+ border-radius: var(--border-radius-sm);
1178
+ background-color: #e2e8f0;
1179
+ overflow: hidden;
1180
+ box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
1181
+ }
1182
+
1183
+ .progress-bar-custom {
1184
+ height: 100%;
1185
+ background: var(--gradient-primary);
1186
+ transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
1187
+ position: relative;
1188
+ overflow: hidden;
1189
+ }
1190
+
1191
+ .progress-bar-custom::after {
1192
+ content: '';
1193
+ position: absolute;
1194
+ top: 0;
1195
+ left: 0;
1196
+ right: 0;
1197
+ bottom: 0;
1198
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
1199
+ animation: progress-shimmer 2s infinite;
1200
+ }
1201
+
1202
+ @keyframes progress-shimmer {
1203
+ 0% { transform: translateX(-100%); }
1204
+ 100% { transform: translateX(100%); }
1205
+ }
1206
+
1207
+ /* Enhanced Tooltip */
1208
+ .tooltip-inner {
1209
+ background-color: var(--dark-color);
1210
+ border-radius: var(--border-radius-sm);
1211
+ font-size: 0.875rem;
1212
+ padding: 0.5rem 0.75rem;
1213
+ box-shadow: var(--box-shadow);
1214
+ }
1215
+
1216
+ /* Enhanced Custom Scrollbar */
1217
+ ::-webkit-scrollbar {
1218
+ width: 10px;
1219
+ height: 10px;
1220
+ }
1221
+
1222
+ ::-webkit-scrollbar-track {
1223
+ background: var(--light-gray);
1224
+ border-radius: var(--border-radius-sm);
1225
+ }
1226
+
1227
+ ::-webkit-scrollbar-thumb {
1228
+ background: var(--gradient-primary);
1229
+ border-radius: var(--border-radius-sm);
1230
+ border: 2px solid var(--light-gray);
1231
+ }
1232
+
1233
+ ::-webkit-scrollbar-thumb:hover {
1234
+ background: var(--gradient-secondary);
1235
+ }
1236
+
1237
+ ::-webkit-scrollbar-corner {
1238
+ background: var(--light-gray);
1239
+ }
1240
+
1241
+ /* Print Styles */
1242
+ @media print {
1243
+ .navbar, .footer, .hero-scroll-indicator, .floating-icon-container {
1244
+ display: none !important;
1245
+ }
1246
+
1247
+ .hero-section {
1248
+ background: white !important;
1249
+ color: black !important;
1250
+ padding: 1rem 0 !important;
1251
+ }
1252
+
1253
+ .card {
1254
+ box-shadow: none !important;
1255
+ border: 1px solid #ddd !important;
1256
+ }
1257
+
1258
+ .btn {
1259
+ border: 1px solid #ddd !important;
1260
+ background: white !important;
1261
+ color: black !important;
1262
+ }
1263
+ }
1264
+
1265
+ /* Playground-Specific Styles */
1266
+ .playground-visual {
1267
+ position: relative;
1268
+ display: flex;
1269
+ justify-content: center;
1270
+ align-items: center;
1271
+ height: 200px;
1272
+ }
1273
+
1274
+ .playground-icon {
1275
+ width: 100px;
1276
+ height: 100px;
1277
+ background: rgba(255, 255, 255, 0.15);
1278
+ border-radius: 50%;
1279
+ display: flex;
1280
+ align-items: center;
1281
+ justify-content: center;
1282
+ font-size: 2.5rem;
1283
+ color: white;
1284
+ backdrop-filter: blur(20px);
1285
+ border: 2px solid rgba(255, 255, 255, 0.3);
1286
+ position: relative;
1287
+ }
1288
+
1289
+ .audio-player-container {
1290
+ border: 2px solid #e2e8f0;
1291
+ transition: var(--transition);
1292
+ }
1293
+
1294
+ .audio-player-container:hover {
1295
+ border-color: var(--primary-color);
1296
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
1297
+ }
1298
+
1299
+ .stat-item {
1300
+ padding: 1rem;
1301
+ text-align: center;
1302
+ }
1303
+
1304
+ .stat-item i {
1305
+ font-size: 1.5rem;
1306
+ margin-bottom: 0.5rem;
1307
+ display: block;
1308
+ }
1309
+
1310
+ .stat-value {
1311
+ font-size: 1.25rem;
1312
+ font-weight: 700;
1313
+ color: var(--dark-color);
1314
+ margin-bottom: 0.25rem;
1315
+ }
1316
+
1317
+ .stat-label {
1318
+ font-size: 0.875rem;
1319
+ color: var(--text-muted);
1320
+ font-weight: 500;
1321
+ }
1322
+
1323
+ .card-header {
1324
+ border-bottom: none;
1325
+ border-radius: var(--border-radius) var(--border-radius) 0 0 !important;
1326
+ }
1327
+
1328
+ /* Enhanced Form Controls for Playground */
1329
+ .playground .form-control,
1330
+ .playground .form-select {
1331
+ border: 2px solid #e2e8f0;
1332
+ border-radius: var(--border-radius-sm);
1333
+ padding: 1rem;
1334
+ font-size: 1rem;
1335
+ transition: var(--transition);
1336
+ }
1337
+
1338
+ .playground .form-control:focus,
1339
+ .playground .form-select:focus {
1340
+ border-color: var(--primary-color);
1341
+ box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1);
1342
+ transform: translateY(-1px);
1343
+ }
1344
+
1345
+ .playground .btn-group .btn {
1346
+ border-radius: var(--border-radius-sm);
1347
+ }
1348
+
1349
+ .playground .btn-group .btn:first-child {
1350
+ border-top-right-radius: 0;
1351
+ border-bottom-right-radius: 0;
1352
+ }
1353
+
1354
+ .playground .btn-group .btn:last-child {
1355
+ border-top-left-radius: 0;
1356
+ border-bottom-left-radius: 0;
1357
+ }
1358
+
1359
+ /* Audio Player Enhancements */
1360
+ audio::-webkit-media-controls-panel {
1361
+ background-color: var(--light-gray);
1362
+ border-radius: var(--border-radius-sm);
1363
+ }
1364
+
1365
+ audio::-webkit-media-controls-play-button,
1366
+ audio::-webkit-media-controls-pause-button {
1367
+ background-color: var(--primary-color);
1368
+ border-radius: 50%;
1369
+ }
1370
+
1371
+ audio::-webkit-media-controls-timeline {
1372
+ background-color: var(--light-gray);
1373
+ border-radius: var(--border-radius-sm);
1374
+ }
1375
+
1376
+ audio::-webkit-media-controls-current-time-display,
1377
+ audio::-webkit-media-controls-time-remaining-display {
1378
+ color: var(--text-color);
1379
+ font-weight: 500;
1380
+ }
1381
+
1382
+ /* Reduced Motion Support */
1383
+ @media (prefers-reduced-motion: reduce) {
1384
+ *,
1385
+ *::before,
1386
+ *::after {
1387
+ animation-duration: 0.01ms !important;
1388
+ animation-iteration-count: 1 !important;
1389
+ transition-duration: 0.01ms !important;
1390
+ }
1391
+
1392
+ .hero-background-animation,
1393
+ .floating-icon,
1394
+ .pulse-ring,
1395
+ .hero-scroll-indicator,
1396
+ .playground-icon {
1397
+ animation: none !important;
1398
+ }
1399
+ }
static/js/i18n.js ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // JavaScript Internationalization Support for TTSFM
2
+
3
+ // Translation data - this will be populated by the server
4
+ window.i18nData = window.i18nData || {};
5
+
6
+ // Current locale
7
+ window.currentLocale = document.documentElement.lang || 'en';
8
+
9
+ // Translation function
10
+ function _(key, params = {}) {
11
+ const keys = key.split('.');
12
+ let value = window.i18nData;
13
+
14
+ // Navigate through the nested object
15
+ for (const k of keys) {
16
+ if (value && typeof value === 'object' && k in value) {
17
+ value = value[k];
18
+ } else {
19
+ // Fallback to key if translation not found
20
+ return key;
21
+ }
22
+ }
23
+
24
+ // If we found a string, apply parameters
25
+ if (typeof value === 'string') {
26
+ return formatString(value, params);
27
+ }
28
+
29
+ // Fallback to key
30
+ return key;
31
+ }
32
+
33
+ // Format string with parameters
34
+ function formatString(str, params) {
35
+ return str.replace(/\{(\w+)\}/g, (match, key) => {
36
+ return params.hasOwnProperty(key) ? params[key] : match;
37
+ });
38
+ }
39
+
40
+ // Load translations from server
41
+ async function loadTranslations() {
42
+ try {
43
+ const response = await fetch(`/api/translations/${window.currentLocale}`);
44
+ if (response.ok) {
45
+ window.i18nData = await response.json();
46
+ }
47
+ } catch (error) {
48
+ console.warn('Failed to load translations:', error);
49
+ }
50
+ }
51
+
52
+ // Sample texts for different languages
53
+ const sampleTexts = {
54
+ en: {
55
+ welcome: "Welcome to TTSFM! This is a free text-to-speech service that converts your text into high-quality audio using advanced AI technology.",
56
+ story: "Once upon a time, in a digital world far away, there lived a small Python package that could transform any text into beautiful speech. This package was called TTSFM, and it brought joy to developers everywhere.",
57
+ technical: "TTSFM is a Python client for text-to-speech APIs that provides both synchronous and asynchronous interfaces. It supports multiple voices and audio formats, making it perfect for various applications.",
58
+ multilingual: "TTSFM supports multiple languages and voices, allowing you to create diverse audio content for global audiences. The service is completely free and requires no API keys.",
59
+ long: "This is a longer text sample designed to test the auto-combine feature of TTSFM. When text exceeds the maximum length limit, TTSFM automatically splits it into smaller chunks, generates audio for each chunk, and then seamlessly combines them into a single audio file. This process is completely transparent to the user and ensures that you can convert text of any length without worrying about technical limitations. The resulting audio maintains consistent quality and natural flow throughout the entire content."
60
+ },
61
+ zh: {
62
+ welcome: "欢迎使用TTSFM!这是一个免费的文本转语音服务,使用先进的AI技术将您的文本转换为高质量音频。",
63
+ story: "很久很久以前,在一个遥远的数字世界里,住着一个小小的Python包,它能够将任何文本转换成美妙的语音。这个包叫做TTSFM,它为世界各地的开发者带来了快乐。",
64
+ technical: "TTSFM是一个用于文本转语音API的Python客户端,提供同步和异步接口。它支持多种声音和音频格式,非常适合各种应用。",
65
+ multilingual: "TTSFM支持多种语言和声音,让您能够为全球受众创建多样化的音频内容。该服务完全免费,无需API密钥。",
66
+ long: "这是一个较长的文本示例,用于测试TTSFM的自动合并功能。当文本超过最大长度限制时,TTSFM会自动将其分割成较小的片段,为每个片段生成音频,然后无缝地将它们合并成一个音频文件。这个过程对用户完全透明,确保您可以转换任何长度的文本,而无需担心技术限制。生成的音频在整个内容中保持一致的质量和自然的流畅性。"
67
+ }
68
+ };
69
+
70
+ // Get sample text for current locale
71
+ function getSampleText(type) {
72
+ const locale = window.currentLocale;
73
+ const texts = sampleTexts[locale] || sampleTexts.en;
74
+ return texts[type] || texts.welcome;
75
+ }
76
+
77
+ // Error messages
78
+ const errorMessages = {
79
+ en: {
80
+ empty_text: "Please enter some text to convert.",
81
+ generation_failed: "Failed to generate speech. Please try again.",
82
+ network_error: "Network error. Please check your connection and try again.",
83
+ invalid_format: "Invalid audio format selected.",
84
+ invalid_voice: "Invalid voice selected.",
85
+ text_too_long: "Text is too long. Please reduce the length or enable auto-combine.",
86
+ server_error: "Server error. Please try again later."
87
+ },
88
+ zh: {
89
+ empty_text: "请输入要转换的文本。",
90
+ generation_failed: "语音生成失败。请重试。",
91
+ network_error: "网络错误。请检查您的连接并重��。",
92
+ invalid_format: "选择的音频格式无效。",
93
+ invalid_voice: "选择的声音无效。",
94
+ text_too_long: "文本太长。请减少长度或启用自动合并。",
95
+ server_error: "服务器错误。请稍后重试。"
96
+ }
97
+ };
98
+
99
+ // Success messages
100
+ const successMessages = {
101
+ en: {
102
+ generation_complete: "Speech generated successfully!",
103
+ text_copied: "Text copied to clipboard!",
104
+ download_started: "Download started!"
105
+ },
106
+ zh: {
107
+ generation_complete: "语音生成成功!",
108
+ text_copied: "文本已复制到剪贴板!",
109
+ download_started: "下载已开始!"
110
+ }
111
+ };
112
+
113
+ // Get error message
114
+ function getErrorMessage(key) {
115
+ const locale = window.currentLocale;
116
+ const messages = errorMessages[locale] || errorMessages.en;
117
+ return messages[key] || key;
118
+ }
119
+
120
+ // Get success message
121
+ function getSuccessMessage(key) {
122
+ const locale = window.currentLocale;
123
+ const messages = successMessages[locale] || successMessages.en;
124
+ return messages[key] || key;
125
+ }
126
+
127
+ // Format file size
128
+ function formatFileSize(bytes) {
129
+ if (bytes === 0) return '0 Bytes';
130
+
131
+ const k = 1024;
132
+ const sizes = window.currentLocale === 'zh'
133
+ ? ['字节', 'KB', 'MB', 'GB']
134
+ : ['Bytes', 'KB', 'MB', 'GB'];
135
+
136
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
137
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
138
+ }
139
+
140
+ // Format duration
141
+ function formatDuration(seconds) {
142
+ if (isNaN(seconds) || seconds < 0) {
143
+ return window.currentLocale === 'zh' ? '未知' : 'Unknown';
144
+ }
145
+
146
+ const minutes = Math.floor(seconds / 60);
147
+ const remainingSeconds = Math.floor(seconds % 60);
148
+
149
+ if (minutes > 0) {
150
+ return window.currentLocale === 'zh'
151
+ ? `${minutes}分${remainingSeconds}秒`
152
+ : `${minutes}m ${remainingSeconds}s`;
153
+ } else {
154
+ return window.currentLocale === 'zh'
155
+ ? `${remainingSeconds}秒`
156
+ : `${remainingSeconds}s`;
157
+ }
158
+ }
159
+
160
+ // Update UI text based on current locale
161
+ function updateUIText() {
162
+ // Update button texts
163
+ const generateBtn = document.getElementById('generate-btn');
164
+ if (generateBtn && !generateBtn.disabled) {
165
+ generateBtn.innerHTML = window.currentLocale === 'zh'
166
+ ? '<i class="fas fa-magic me-2"></i>生成语音'
167
+ : '<i class="fas fa-magic me-2"></i>Generate Speech';
168
+ }
169
+
170
+ // Update other dynamic text elements
171
+ const charCountElement = document.querySelector('#char-count');
172
+ if (charCountElement) {
173
+ const count = charCountElement.textContent;
174
+ const parent = charCountElement.parentElement;
175
+ if (parent) {
176
+ // Escape HTML characters to prevent XSS
177
+ const escapedCount = count.replace(/&/g, '&amp;')
178
+ .replace(/</g, '&lt;')
179
+ .replace(/>/g, '&gt;')
180
+ .replace(/"/g, '&quot;')
181
+ .replace(/'/g, '&#x27;');
182
+
183
+ parent.innerHTML = window.currentLocale === 'zh'
184
+ ? `<i class="fas fa-keyboard me-1"></i><span id="char-count">${escapedCount}</span> 字符`
185
+ : `<i class="fas fa-keyboard me-1"></i><span id="char-count">${escapedCount}</span> characters`;
186
+ }
187
+ }
188
+ }
189
+
190
+ // Initialize i18n
191
+ function initI18n() {
192
+ // Load translations if needed
193
+ loadTranslations();
194
+
195
+ // Update UI text
196
+ updateUIText();
197
+
198
+ // Listen for language changes
199
+ document.addEventListener('languageChanged', function(event) {
200
+ window.currentLocale = event.detail.locale;
201
+ loadTranslations().then(() => {
202
+ updateUIText();
203
+ });
204
+ });
205
+ }
206
+
207
+ // Export functions for global use
208
+ window._ = _;
209
+ window.getSampleText = getSampleText;
210
+ window.getErrorMessage = getErrorMessage;
211
+ window.getSuccessMessage = getSuccessMessage;
212
+ window.formatFileSize = formatFileSize;
213
+ window.formatDuration = formatDuration;
214
+ window.initI18n = initI18n;
215
+
216
+ // Auto-initialize when DOM is ready
217
+ if (document.readyState === 'loading') {
218
+ document.addEventListener('DOMContentLoaded', initI18n);
219
+ } else {
220
+ initI18n();
221
+ }
static/js/playground-enhanced-fixed.js ADDED
@@ -0,0 +1,712 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // TTSFM Enhanced Playground with WebSocket Streaming Support - Fixed Version
2
+
3
+ // Global variables
4
+ let currentAudioBlob = null;
5
+ let currentFormat = 'mp3';
6
+ let batchResults = [];
7
+ let wsClient = null;
8
+ let streamingMode = false;
9
+ let currentStreamRequest = null;
10
+
11
+ // Initialize playground
12
+ document.addEventListener('DOMContentLoaded', function() {
13
+ initializePlayground();
14
+ initializeWebSocket();
15
+ });
16
+
17
+ // Initialize WebSocket client
18
+ function initializeWebSocket() {
19
+ // Check if Socket.IO is available
20
+ if (typeof io === 'undefined') {
21
+ console.warn('Socket.IO not loaded. WebSocket streaming will be disabled.');
22
+ return;
23
+ }
24
+
25
+ // Initialize WebSocket client
26
+ wsClient = new WebSocketTTSClient({
27
+ socketUrl: window.location.origin,
28
+ debug: true,
29
+ onConnect: () => {
30
+ console.log('WebSocket connected');
31
+ updateStreamingStatus('connected');
32
+ },
33
+ onDisconnect: () => {
34
+ console.log('WebSocket disconnected');
35
+ updateStreamingStatus('disconnected');
36
+ },
37
+ onError: (error) => {
38
+ console.error('WebSocket error:', error);
39
+ updateStreamingStatus('error');
40
+ }
41
+ });
42
+ }
43
+
44
+ // Update streaming status indicator
45
+ function updateStreamingStatus(status) {
46
+ const indicator = document.getElementById('streaming-indicator');
47
+ if (!indicator) return;
48
+
49
+ indicator.className = 'streaming-status';
50
+ switch(status) {
51
+ case 'connected':
52
+ indicator.classList.add('connected');
53
+ indicator.innerHTML = '<i class="fas fa-bolt"></i> Streaming Ready';
54
+ enableStreamingMode(true);
55
+ break;
56
+ case 'disconnected':
57
+ indicator.classList.add('disconnected');
58
+ indicator.innerHTML = '<i class="fas fa-plug"></i> Streaming Offline';
59
+ enableStreamingMode(false);
60
+ break;
61
+ case 'error':
62
+ indicator.classList.add('error');
63
+ indicator.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Connection Error';
64
+ enableStreamingMode(false);
65
+ break;
66
+ case 'streaming':
67
+ indicator.classList.add('streaming');
68
+ indicator.innerHTML = '<i class="fas fa-stream"></i> Streaming...';
69
+ break;
70
+ }
71
+ }
72
+
73
+ // Enable/disable streaming mode
74
+ function enableStreamingMode(enabled) {
75
+ const streamToggle = document.getElementById('stream-mode-toggle');
76
+ if (streamToggle) {
77
+ streamToggle.disabled = !enabled;
78
+ if (!enabled && streamingMode) {
79
+ streamingMode = false;
80
+ streamToggle.checked = false;
81
+ }
82
+ }
83
+ }
84
+
85
+ // Check authentication status
86
+ async function checkAuthStatus() {
87
+ try {
88
+ const response = await fetch('/api/auth-status');
89
+ const data = await response.json();
90
+
91
+ const apiKeySection = document.getElementById('api-key-section');
92
+ if (apiKeySection) {
93
+ if (data.api_key_required) {
94
+ apiKeySection.style.display = 'block';
95
+ const apiKeyInput = document.getElementById('api-key-input');
96
+ if (apiKeyInput) {
97
+ apiKeyInput.required = true;
98
+ }
99
+ } else {
100
+ apiKeySection.style.display = 'none';
101
+ }
102
+ }
103
+ } catch (error) {
104
+ console.warn('Could not check auth status:', error);
105
+ }
106
+ }
107
+
108
+ function initializePlayground() {
109
+ console.log('Initializing enhanced playground...');
110
+ checkAuthStatus();
111
+ loadVoices();
112
+ loadFormats();
113
+ updateCharCount();
114
+ setupEventListeners();
115
+ setupStreamingControls();
116
+ console.log('Enhanced playground initialization complete');
117
+ }
118
+
119
+ function setupStreamingControls() {
120
+ // Add streaming mode toggle
121
+ const generateButton = document.getElementById('generate-btn');
122
+ if (generateButton && generateButton.parentElement) {
123
+ const streamingControls = document.createElement('div');
124
+ streamingControls.className = 'streaming-controls mt-3';
125
+ streamingControls.innerHTML = `
126
+ <div class="form-check form-switch">
127
+ <input class="form-check-input" type="checkbox" id="stream-mode-toggle" disabled>
128
+ <label class="form-check-label" for="stream-mode-toggle">
129
+ <i class="fas fa-bolt me-1"></i>
130
+ Enable WebSocket Streaming
131
+ <small class="text-muted">(Real-time audio chunks)</small>
132
+ </label>
133
+ </div>
134
+ <div id="streaming-indicator" class="streaming-status mt-2"></div>
135
+ `;
136
+ generateButton.parentElement.appendChild(streamingControls);
137
+
138
+ // Add toggle event listener
139
+ const toggle = document.getElementById('stream-mode-toggle');
140
+ if (toggle) {
141
+ toggle.addEventListener('change', (e) => {
142
+ streamingMode = e.target.checked;
143
+ console.log('Streaming mode:', streamingMode ? 'ON' : 'OFF');
144
+
145
+ // Update button text
146
+ const btnText = generateButton.querySelector('.btn-text');
147
+ if (btnText) {
148
+ if (streamingMode) {
149
+ btnText.innerHTML = '<i class="fas fa-bolt me-2"></i>Stream Speech';
150
+ } else {
151
+ btnText.innerHTML = '<i class="fas fa-magic me-2"></i>' +
152
+ (window.currentLocale === 'zh' ? '生成语音' : 'Generate Speech');
153
+ }
154
+ }
155
+ });
156
+ }
157
+ }
158
+
159
+ // Add streaming progress section and error message div
160
+ const audioResult = document.getElementById('audio-result');
161
+ if (audioResult && audioResult.parentElement) {
162
+ // Add error message div
163
+ const errorDiv = document.createElement('div');
164
+ errorDiv.id = 'error-message';
165
+ errorDiv.className = 'alert alert-danger';
166
+ errorDiv.style.display = 'none';
167
+ audioResult.parentElement.insertBefore(errorDiv, audioResult);
168
+
169
+ // Add loading section
170
+ const loadingDiv = document.createElement('div');
171
+ loadingDiv.id = 'loading-section';
172
+ loadingDiv.className = 'text-center';
173
+ loadingDiv.style.display = 'none';
174
+ loadingDiv.innerHTML = `
175
+ <div class="spinner-border text-primary" role="status">
176
+ <span class="visually-hidden">Loading...</span>
177
+ </div>
178
+ <p class="mt-2">Generating speech...</p>
179
+ `;
180
+ audioResult.parentElement.insertBefore(loadingDiv, audioResult);
181
+
182
+ // Add progress section
183
+ const progressSection = document.createElement('div');
184
+ progressSection.id = 'streaming-progress';
185
+ progressSection.className = 'streaming-progress-section';
186
+ progressSection.style.display = 'none';
187
+ progressSection.innerHTML = `
188
+ <div class="card border-primary">
189
+ <div class="card-body">
190
+ <h5 class="card-title">
191
+ <i class="fas fa-stream me-2"></i>Streaming Progress
192
+ </h5>
193
+ <div class="progress mb-3" style="height: 25px;">
194
+ <div class="progress-bar progress-bar-striped progress-bar-animated"
195
+ id="stream-progress-bar"
196
+ role="progressbar"
197
+ style="width: 0%">
198
+ <span id="stream-progress-text">0%</span>
199
+ </div>
200
+ </div>
201
+ <div class="row text-center">
202
+ <div class="col-md-4">
203
+ <h6>Chunks</h6>
204
+ <p class="h5"><span id="chunks-count">0</span> / <span id="total-chunks">0</span></p>
205
+ </div>
206
+ <div class="col-md-4">
207
+ <h6>Data</h6>
208
+ <p class="h5" id="data-transferred">0 KB</p>
209
+ </div>
210
+ <div class="col-md-4">
211
+ <h6>Time</h6>
212
+ <p class="h5" id="stream-time">0.0s</p>
213
+ </div>
214
+ </div>
215
+ <div id="chunks-visualization" class="chunks-visual mt-3"></div>
216
+ </div>
217
+ </div>
218
+ `;
219
+ audioResult.parentElement.insertBefore(progressSection, audioResult);
220
+ }
221
+ }
222
+
223
+ function setupEventListeners() {
224
+ console.log('Setting up event listeners...');
225
+
226
+ // Form and input events
227
+ const textInput = document.getElementById('text-input');
228
+ if (textInput) {
229
+ textInput.addEventListener('input', updateCharCount);
230
+ }
231
+
232
+ // Form submit
233
+ const form = document.getElementById('tts-form');
234
+ if (form) {
235
+ form.addEventListener('submit', function(event) {
236
+ event.preventDefault();
237
+ event.stopPropagation();
238
+
239
+ if (streamingMode && wsClient && wsClient.isConnected()) {
240
+ generateSpeechStreaming(event);
241
+ } else {
242
+ generateSpeech(event);
243
+ }
244
+
245
+ return false;
246
+ });
247
+ }
248
+
249
+ // Download button
250
+ const downloadBtn = document.getElementById('download-btn');
251
+ if (downloadBtn) {
252
+ downloadBtn.addEventListener('click', downloadAudio);
253
+ }
254
+ }
255
+
256
+ // Generate speech using WebSocket streaming
257
+ async function generateSpeechStreaming(event) {
258
+ event.preventDefault();
259
+
260
+ const text = document.getElementById('text-input').value.trim();
261
+ const voice = document.getElementById('voice-select').value;
262
+ const format = document.getElementById('format-select').value;
263
+
264
+ if (!text) {
265
+ showError('Please enter some text to convert');
266
+ return;
267
+ }
268
+
269
+ // Reset UI
270
+ hideError();
271
+ hideResults();
272
+ disableForm();
273
+
274
+ // Show streaming progress
275
+ const progressSection = document.getElementById('streaming-progress');
276
+ if (progressSection) progressSection.style.display = 'block';
277
+
278
+ // Reset progress
279
+ updateStreamingProgress(0, 0, 0);
280
+ const chunksViz = document.getElementById('chunks-visualization');
281
+ if (chunksViz) chunksViz.innerHTML = '';
282
+
283
+ // Update status
284
+ updateStreamingStatus('streaming');
285
+
286
+ const startTime = Date.now();
287
+ let audioChunks = [];
288
+
289
+ try {
290
+ const result = await wsClient.generateSpeech(text, {
291
+ voice: voice,
292
+ format: format,
293
+ chunkSize: 512,
294
+ onStart: (data) => {
295
+ currentStreamRequest = data.request_id;
296
+ console.log('Streaming started:', data);
297
+ },
298
+ onProgress: (progress) => {
299
+ updateStreamingProgress(
300
+ progress.progress,
301
+ progress.chunksCompleted,
302
+ progress.totalChunks
303
+ );
304
+
305
+ const elapsed = (Date.now() - startTime) / 1000;
306
+ const timeEl = document.getElementById('stream-time');
307
+ if (timeEl) timeEl.textContent = `${elapsed.toFixed(1)}s`;
308
+ },
309
+ onChunk: (chunk) => {
310
+ // Visualize chunk
311
+ const chunksViz = document.getElementById('chunks-visualization');
312
+ if (chunksViz) {
313
+ const chunkViz = document.createElement('div');
314
+ chunkViz.className = 'chunk-indicator';
315
+ chunkViz.title = `Chunk ${chunk.chunkIndex + 1} - ${(chunk.audioData.byteLength / 1024).toFixed(1)}KB`;
316
+ chunkViz.innerHTML = `<i class="fas fa-music"></i>`;
317
+ chunksViz.appendChild(chunkViz);
318
+ }
319
+
320
+ // Update data transferred
321
+ const dataEl = document.getElementById('data-transferred');
322
+ if (dataEl) {
323
+ const currentData = parseFloat(dataEl.textContent) || 0;
324
+ const newData = currentData + (chunk.audioData.byteLength / 1024);
325
+ dataEl.textContent = `${newData.toFixed(1)} KB`;
326
+ }
327
+
328
+ audioChunks.push(chunk);
329
+ },
330
+ onComplete: (result) => {
331
+ console.log('Streaming complete:', result);
332
+
333
+ // Create blob from audio data
334
+ currentAudioBlob = new Blob([result.audioData], { type: `audio/${result.format}` });
335
+ currentFormat = result.format;
336
+
337
+ // Show results
338
+ showResults(currentAudioBlob, result.format);
339
+
340
+ // Update final stats
341
+ const totalTime = (Date.now() - startTime) / 1000;
342
+ showStreamingStats({
343
+ chunks: result.chunks.length,
344
+ totalSize: (result.audioData.byteLength / 1024).toFixed(1),
345
+ totalTime: totalTime.toFixed(2),
346
+ format: result.format
347
+ });
348
+ },
349
+ onError: (error) => {
350
+ showError(`Streaming error: ${error.message}`);
351
+ enableForm();
352
+ if (progressSection) progressSection.style.display = 'none';
353
+ }
354
+ });
355
+
356
+ } catch (error) {
357
+ showError(`Failed to stream speech: ${error.message}`);
358
+ enableForm();
359
+ if (progressSection) progressSection.style.display = 'none';
360
+ } finally {
361
+ updateStreamingStatus('connected');
362
+ currentStreamRequest = null;
363
+ }
364
+ }
365
+
366
+ function updateStreamingProgress(progress, chunks, totalChunks) {
367
+ const progressBar = document.getElementById('stream-progress-bar');
368
+ const progressText = document.getElementById('stream-progress-text');
369
+ const chunksCount = document.getElementById('chunks-count');
370
+ const totalChunksEl = document.getElementById('total-chunks');
371
+
372
+ if (progressBar) {
373
+ progressBar.style.width = `${progress}%`;
374
+ if (progressText) progressText.textContent = `${progress}%`;
375
+ }
376
+ if (chunksCount) chunksCount.textContent = chunks;
377
+ if (totalChunksEl) totalChunksEl.textContent = totalChunks;
378
+ }
379
+
380
+ function showStreamingStats(stats) {
381
+ const progressSection = document.getElementById('streaming-progress');
382
+ if (!progressSection) return;
383
+
384
+ const statsHtml = `
385
+ <div class="alert alert-success mt-3">
386
+ <h6><i class="fas fa-check-circle me-2"></i>Streaming Complete!</h6>
387
+ <div class="row mt-2">
388
+ <div class="col-md-3">
389
+ <strong>Chunks:</strong> ${stats.chunks}
390
+ </div>
391
+ <div class="col-md-3">
392
+ <strong>Total Size:</strong> ${stats.totalSize} KB
393
+ </div>
394
+ <div class="col-md-3">
395
+ <strong>Time:</strong> ${stats.totalTime}s
396
+ </div>
397
+ <div class="col-md-3">
398
+ <strong>Format:</strong> ${stats.format.toUpperCase()}
399
+ </div>
400
+ </div>
401
+ </div>
402
+ `;
403
+
404
+ const statsDiv = document.createElement('div');
405
+ statsDiv.innerHTML = statsHtml;
406
+ progressSection.appendChild(statsDiv);
407
+ }
408
+
409
+ // Load available voices
410
+ async function loadVoices() {
411
+ try {
412
+ const response = await fetch('/api/voices');
413
+ const data = await response.json();
414
+
415
+ const voiceSelect = document.getElementById('voice-select');
416
+ if (voiceSelect) {
417
+ voiceSelect.innerHTML = '';
418
+
419
+ data.voices.forEach(voice => {
420
+ const option = document.createElement('option');
421
+ option.value = voice.id;
422
+ option.textContent = voice.name;
423
+ if (voice.id === 'alloy') {
424
+ option.selected = true;
425
+ }
426
+ voiceSelect.appendChild(option);
427
+ });
428
+ }
429
+ } catch (error) {
430
+ console.error('Failed to load voices:', error);
431
+ }
432
+ }
433
+
434
+ // Load available formats
435
+ async function loadFormats() {
436
+ try {
437
+ const response = await fetch('/api/formats');
438
+ const data = await response.json();
439
+
440
+ const formatSelect = document.getElementById('format-select');
441
+ if (formatSelect) {
442
+ formatSelect.innerHTML = '';
443
+
444
+ data.formats.forEach(format => {
445
+ const option = document.createElement('option');
446
+ option.value = format.id;
447
+ option.textContent = `${format.name} - ${format.quality}`;
448
+ if (format.id === 'mp3') {
449
+ option.selected = true;
450
+ }
451
+ formatSelect.appendChild(option);
452
+ });
453
+ }
454
+ } catch (error) {
455
+ console.error('Failed to load formats:', error);
456
+ }
457
+ }
458
+
459
+ // Update character count
460
+ function updateCharCount() {
461
+ const textInput = document.getElementById('text-input');
462
+ const charCount = document.getElementById('char-count');
463
+ const maxLengthInput = document.getElementById('max-length-input');
464
+
465
+ if (textInput && charCount) {
466
+ const currentLength = textInput.value.length;
467
+ const maxLength = maxLengthInput ? parseInt(maxLengthInput.value) : 4096;
468
+
469
+ charCount.textContent = currentLength;
470
+
471
+ if (currentLength > maxLength) {
472
+ charCount.className = 'text-danger fw-bold';
473
+ } else if (currentLength > maxLength * 0.8) {
474
+ charCount.className = 'text-warning fw-bold';
475
+ } else {
476
+ charCount.className = '';
477
+ }
478
+ }
479
+ }
480
+
481
+ // Generate speech (original HTTP method)
482
+ async function generateSpeech(event) {
483
+ event.preventDefault();
484
+
485
+ const text = document.getElementById('text-input').value.trim();
486
+ const voice = document.getElementById('voice-select').value;
487
+ const format = document.getElementById('format-select').value;
488
+ const instructions = document.getElementById('instructions-input')?.value.trim() || '';
489
+ const apiKey = document.getElementById('api-key-input')?.value.trim() || '';
490
+
491
+ if (!text) {
492
+ showError('Please enter some text to convert');
493
+ return;
494
+ }
495
+
496
+ hideError();
497
+ hideResults();
498
+ showLoading();
499
+ disableForm();
500
+
501
+ try {
502
+ const headers = {
503
+ 'Content-Type': 'application/json'
504
+ };
505
+
506
+ if (apiKey) {
507
+ headers['Authorization'] = `Bearer ${apiKey}`;
508
+ }
509
+
510
+ const requestBody = {
511
+ text: text,
512
+ voice: voice,
513
+ format: format
514
+ };
515
+
516
+ if (instructions) {
517
+ requestBody.instructions = instructions;
518
+ }
519
+
520
+ const response = await fetch('/api/generate', {
521
+ method: 'POST',
522
+ headers: headers,
523
+ body: JSON.stringify(requestBody)
524
+ });
525
+
526
+ if (!response.ok) {
527
+ let errorMessage = `Error: ${response.status} ${response.statusText}`;
528
+ try {
529
+ const errorData = await response.json();
530
+ if (errorData.error?.message) {
531
+ errorMessage = errorData.error.message;
532
+ }
533
+ } catch (e) {
534
+ // Use default error message
535
+ }
536
+ throw new Error(errorMessage);
537
+ }
538
+
539
+ const blob = await response.blob();
540
+ currentAudioBlob = blob;
541
+ currentFormat = format;
542
+
543
+ showResults(blob, format);
544
+
545
+ } catch (error) {
546
+ showError(error.message);
547
+ } finally {
548
+ hideLoading();
549
+ enableForm();
550
+ }
551
+ }
552
+
553
+ // Show/hide functions
554
+ function showLoading() {
555
+ const loading = document.getElementById('loading-section');
556
+ if (loading) loading.style.display = 'block';
557
+ }
558
+
559
+ function hideLoading() {
560
+ const loading = document.getElementById('loading-section');
561
+ if (loading) loading.style.display = 'none';
562
+ }
563
+
564
+ function showResults(blob, format) {
565
+ const audioUrl = URL.createObjectURL(blob);
566
+ const audioPlayer = document.getElementById('audio-player');
567
+ if (audioPlayer) {
568
+ audioPlayer.src = audioUrl;
569
+ }
570
+
571
+ const audioResult = document.getElementById('audio-result');
572
+ if (audioResult) {
573
+ audioResult.classList.remove('d-none');
574
+ }
575
+
576
+ const downloadBtn = document.getElementById('download-btn');
577
+ if (downloadBtn) {
578
+ downloadBtn.disabled = false;
579
+ }
580
+
581
+ enableForm();
582
+ }
583
+
584
+ function hideResults() {
585
+ const audioResult = document.getElementById('audio-result');
586
+ if (audioResult) {
587
+ audioResult.classList.add('d-none');
588
+ }
589
+ }
590
+
591
+ function showError(message) {
592
+ const errorDiv = document.getElementById('error-message');
593
+ if (errorDiv) {
594
+ errorDiv.textContent = message;
595
+ errorDiv.style.display = 'block';
596
+ }
597
+ }
598
+
599
+ function hideError() {
600
+ const errorDiv = document.getElementById('error-message');
601
+ if (errorDiv) {
602
+ errorDiv.style.display = 'none';
603
+ }
604
+ }
605
+
606
+ function disableForm() {
607
+ const elements = ['generate-btn', 'text-input', 'voice-select', 'format-select'];
608
+ elements.forEach(id => {
609
+ const el = document.getElementById(id);
610
+ if (el) el.disabled = true;
611
+ });
612
+ }
613
+
614
+ function enableForm() {
615
+ const elements = ['generate-btn', 'text-input', 'voice-select', 'format-select'];
616
+ elements.forEach(id => {
617
+ const el = document.getElementById(id);
618
+ if (el) el.disabled = false;
619
+ });
620
+ }
621
+
622
+ // Download audio
623
+ function downloadAudio() {
624
+ if (!currentAudioBlob) return;
625
+
626
+ const url = URL.createObjectURL(currentAudioBlob);
627
+ const a = document.createElement('a');
628
+ a.href = url;
629
+ a.download = `tts_${Date.now()}.${currentFormat}`;
630
+ a.click();
631
+ URL.revokeObjectURL(url);
632
+ }
633
+
634
+ // Add CSS for streaming visualization
635
+ const style = document.createElement('style');
636
+ style.textContent = `
637
+ .streaming-controls {
638
+ padding: 15px;
639
+ background-color: #f8f9fa;
640
+ border-radius: 8px;
641
+ }
642
+
643
+ .streaming-status {
644
+ display: inline-block;
645
+ padding: 5px 10px;
646
+ border-radius: 20px;
647
+ font-size: 0.875rem;
648
+ font-weight: 500;
649
+ }
650
+
651
+ .streaming-status.connected {
652
+ background-color: #d4edda;
653
+ color: #155724;
654
+ }
655
+
656
+ .streaming-status.disconnected {
657
+ background-color: #f8d7da;
658
+ color: #721c24;
659
+ }
660
+
661
+ .streaming-status.error {
662
+ background-color: #fff3cd;
663
+ color: #856404;
664
+ }
665
+
666
+ .streaming-status.streaming {
667
+ background-color: #cce5ff;
668
+ color: #004085;
669
+ animation: pulse 1.5s infinite;
670
+ }
671
+
672
+ @keyframes pulse {
673
+ 0% { opacity: 1; }
674
+ 50% { opacity: 0.7; }
675
+ 100% { opacity: 1; }
676
+ }
677
+
678
+ .streaming-progress-section {
679
+ margin-bottom: 20px;
680
+ }
681
+
682
+ .chunks-visual {
683
+ display: flex;
684
+ flex-wrap: wrap;
685
+ gap: 5px;
686
+ }
687
+
688
+ .chunk-indicator {
689
+ width: 30px;
690
+ height: 30px;
691
+ background-color: #007bff;
692
+ color: white;
693
+ border-radius: 4px;
694
+ display: flex;
695
+ align-items: center;
696
+ justify-content: center;
697
+ font-size: 0.75rem;
698
+ animation: chunkAppear 0.3s ease-out;
699
+ }
700
+
701
+ @keyframes chunkAppear {
702
+ from {
703
+ transform: scale(0);
704
+ opacity: 0;
705
+ }
706
+ to {
707
+ transform: scale(1);
708
+ opacity: 1;
709
+ }
710
+ }
711
+ `;
712
+ document.head.appendChild(style);
static/js/playground.js ADDED
@@ -0,0 +1,861 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // TTSFM Playground JavaScript
2
+
3
+ // Global variables
4
+ let currentAudioBlob = null;
5
+ let currentFormat = 'mp3';
6
+ let batchResults = [];
7
+
8
+ // Initialize playground
9
+ document.addEventListener('DOMContentLoaded', function() {
10
+ initializePlayground();
11
+ });
12
+
13
+ // Check authentication status and show/hide API key field
14
+ async function checkAuthStatus() {
15
+ try {
16
+ const response = await fetch('/api/auth-status');
17
+ const data = await response.json();
18
+
19
+ const apiKeySection = document.getElementById('api-key-section');
20
+ if (apiKeySection) {
21
+ if (data.api_key_required) {
22
+ // Show API key field and mark as required
23
+ apiKeySection.style.display = 'block';
24
+ const apiKeyInput = document.getElementById('api-key-input');
25
+ const label = apiKeySection.querySelector('label');
26
+
27
+ if (apiKeyInput) {
28
+ apiKeyInput.required = true;
29
+ apiKeyInput.placeholder = 'Enter your API key (required)';
30
+ }
31
+
32
+ if (label) {
33
+ label.innerHTML = '<i class="fas fa-key me-2"></i>' + (window.currentLocale === 'zh' ? 'API密钥(必需)' : 'API Key (Required)');
34
+ }
35
+
36
+ // Update form text
37
+ const formText = apiKeySection.querySelector('.form-text');
38
+ if (formText) {
39
+ formText.innerHTML = '<i class="fas fa-exclamation-triangle me-1 text-warning"></i>API key protection is enabled - this field is required';
40
+ }
41
+ } else {
42
+ // Hide API key field or mark as optional
43
+ apiKeySection.style.display = 'none';
44
+ }
45
+ }
46
+ } catch (error) {
47
+ console.warn('Could not check auth status:', error);
48
+ // If we can't check, assume API key might be required and show the field
49
+ const apiKeySection = document.getElementById('api-key-section');
50
+ if (apiKeySection) {
51
+ apiKeySection.style.display = 'block';
52
+ }
53
+ }
54
+ }
55
+
56
+ function initializePlayground() {
57
+ console.log('Initializing playground...');
58
+ checkAuthStatus();
59
+ loadVoices();
60
+ loadFormats();
61
+ updateCharCount();
62
+ setupEventListeners();
63
+ console.log('Playground initialization complete');
64
+
65
+ // Initialize tooltips if Bootstrap is available
66
+ if (typeof bootstrap !== 'undefined') {
67
+ const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
68
+ tooltipTriggerList.map(function (tooltipTriggerEl) {
69
+ return new bootstrap.Tooltip(tooltipTriggerEl);
70
+ });
71
+ }
72
+ }
73
+
74
+ function setupEventListeners() {
75
+ console.log('Setting up event listeners...');
76
+
77
+ // Form and input events
78
+ const textInput = document.getElementById('text-input');
79
+ if (textInput) {
80
+ textInput.addEventListener('input', updateCharCount);
81
+ console.log('Text input event listener added');
82
+ } else {
83
+ console.error('Text input element not found!');
84
+ }
85
+
86
+ // Add form submit event listener with better error handling
87
+ const form = document.getElementById('tts-form');
88
+ if (form) {
89
+ form.addEventListener('submit', function(event) {
90
+ console.log('Form submit event triggered');
91
+ event.preventDefault(); // Prevent default form submission
92
+ event.stopPropagation(); // Stop event bubbling
93
+ generateSpeech(event);
94
+ return false; // Additional prevention
95
+ });
96
+ } else {
97
+ console.error('TTS form not found!');
98
+ }
99
+
100
+ const maxLengthInput = document.getElementById('max-length-input');
101
+ if (maxLengthInput) {
102
+ maxLengthInput.addEventListener('input', updateCharCount);
103
+ console.log('Max length input event listener added');
104
+ } else {
105
+ console.error('Max length input element not found!');
106
+ }
107
+
108
+ const autoCombineCheck = document.getElementById('auto-combine-check');
109
+ if (autoCombineCheck) {
110
+ autoCombineCheck.addEventListener('change', updateAutoCombineStatus);
111
+ }
112
+
113
+ // Enhanced button events
114
+ const validateBtn = document.getElementById('validate-text-btn');
115
+ if (validateBtn) {
116
+ validateBtn.addEventListener('click', validateText);
117
+ console.log('Validate button event listener added');
118
+ } else {
119
+ console.error('Validate button not found!');
120
+ }
121
+
122
+ const randomBtn = document.getElementById('random-text-btn');
123
+ if (randomBtn) {
124
+ randomBtn.addEventListener('click', loadRandomText);
125
+ console.log('Random text button event listener added');
126
+ } else {
127
+ console.error('Random text button not found!');
128
+ }
129
+
130
+ const downloadBtn = document.getElementById('download-btn');
131
+ if (downloadBtn) {
132
+ downloadBtn.addEventListener('click', downloadAudio);
133
+ console.log('Download button event listener added');
134
+ } else {
135
+ console.error('Download button not found!');
136
+ }
137
+
138
+ // Add direct click event listener for generate button as backup
139
+ const generateBtn = document.getElementById('generate-btn');
140
+ if (generateBtn) {
141
+ generateBtn.addEventListener('click', function(event) {
142
+ console.log('Generate button clicked directly');
143
+ event.preventDefault();
144
+ event.stopPropagation();
145
+ generateSpeech(event);
146
+ return false;
147
+ });
148
+ }
149
+
150
+ // New button events
151
+ const clearTextBtn = document.getElementById('clear-text-btn');
152
+ if (clearTextBtn) {
153
+ clearTextBtn.addEventListener('click', clearText);
154
+ }
155
+
156
+
157
+
158
+ const resetFormBtn = document.getElementById('reset-form-btn');
159
+ if (resetFormBtn) {
160
+ resetFormBtn.addEventListener('click', resetForm);
161
+ }
162
+
163
+ const replayBtn = document.getElementById('replay-btn');
164
+ if (replayBtn) {
165
+ replayBtn.addEventListener('click', replayAudio);
166
+ }
167
+
168
+ const shareBtn = document.getElementById('share-btn');
169
+ if (shareBtn) {
170
+ shareBtn.addEventListener('click', shareAudio);
171
+ }
172
+
173
+ // API Key visibility toggle
174
+ const toggleApiKeyBtn = document.getElementById('toggle-api-key-visibility');
175
+ if (toggleApiKeyBtn) {
176
+ toggleApiKeyBtn.addEventListener('click', toggleApiKeyVisibility);
177
+ }
178
+
179
+ // Voice and format selection events
180
+ const voiceSelect = document.getElementById('voice-select');
181
+ if (voiceSelect) {
182
+ voiceSelect.addEventListener('change', updateVoiceInfo);
183
+ console.log('Voice select event listener added');
184
+ } else {
185
+ console.error('Voice select element not found!');
186
+ }
187
+
188
+ const formatSelect = document.getElementById('format-select');
189
+ if (formatSelect) {
190
+ formatSelect.addEventListener('change', updateFormatInfo);
191
+ console.log('Format select event listener added');
192
+ } else {
193
+ console.error('Format select element not found!');
194
+ }
195
+
196
+ // Example text buttons
197
+ document.querySelectorAll('.use-example').forEach(button => {
198
+ button.addEventListener('click', function() {
199
+ document.getElementById('text-input').value = this.dataset.text;
200
+ updateCharCount();
201
+ // Add visual feedback
202
+ this.classList.add('btn-success');
203
+ setTimeout(() => {
204
+ this.classList.remove('btn-success');
205
+ this.classList.add('btn-outline-primary');
206
+ }, 1000);
207
+ });
208
+ });
209
+
210
+ // Keyboard shortcuts
211
+ document.addEventListener('keydown', function(e) {
212
+ // Ctrl/Cmd + Enter to generate speech
213
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
214
+ e.preventDefault();
215
+ document.getElementById('generate-btn').click();
216
+ }
217
+
218
+ // Escape to clear results
219
+ if (e.key === 'Escape') {
220
+ clearResults();
221
+ }
222
+ });
223
+
224
+ // Initialize auto-combine status
225
+ updateAutoCombineStatus();
226
+ }
227
+
228
+ async function loadVoices() {
229
+ try {
230
+ // Prepare headers for API key if available (OpenAI compatible format)
231
+ const headers = {};
232
+ const apiKeyInput = document.getElementById('api-key-input');
233
+ if (apiKeyInput && apiKeyInput.value.trim()) {
234
+ headers['Authorization'] = `Bearer ${apiKeyInput.value.trim()}`;
235
+ }
236
+
237
+ const response = await fetch('/api/voices', { headers });
238
+ const data = await response.json();
239
+
240
+ const select = document.getElementById('voice-select');
241
+ select.innerHTML = '';
242
+
243
+ data.voices.forEach(voice => {
244
+ const option = document.createElement('option');
245
+ option.value = voice.id;
246
+ option.textContent = `${voice.name} - ${voice.description}`;
247
+ select.appendChild(option);
248
+ });
249
+
250
+ // Select default voice
251
+ select.value = 'alloy';
252
+
253
+ } catch (error) {
254
+ console.error('Failed to load voices:', error);
255
+ console.log('Failed to load voices. Please refresh the page.');
256
+ }
257
+ }
258
+
259
+ async function loadFormats() {
260
+ try {
261
+ // Prepare headers for API key if available (OpenAI compatible format)
262
+ const headers = {};
263
+ const apiKeyInput = document.getElementById('api-key-input');
264
+ if (apiKeyInput && apiKeyInput.value.trim()) {
265
+ headers['Authorization'] = `Bearer ${apiKeyInput.value.trim()}`;
266
+ }
267
+
268
+ const response = await fetch('/api/formats', { headers });
269
+ const data = await response.json();
270
+
271
+ const select = document.getElementById('format-select');
272
+ select.innerHTML = '';
273
+
274
+ data.formats.forEach(format => {
275
+ const option = document.createElement('option');
276
+ option.value = format.id;
277
+ option.textContent = `${format.name} - ${format.description}`;
278
+ select.appendChild(option);
279
+ });
280
+
281
+ // Select default format
282
+ select.value = 'mp3';
283
+ updateFormatInfo();
284
+
285
+ } catch (error) {
286
+ console.error('Failed to load formats:', error);
287
+ console.log('Failed to load formats. Please refresh the page.');
288
+ }
289
+ }
290
+
291
+ function updateCharCount() {
292
+ const textInput = document.getElementById('text-input');
293
+ const maxLengthInput = document.getElementById('max-length-input');
294
+ const charCountElement = document.getElementById('char-count');
295
+
296
+ if (!textInput || !maxLengthInput || !charCountElement) {
297
+ console.warn('Required elements not found for updateCharCount');
298
+ return;
299
+ }
300
+
301
+ const text = textInput.value;
302
+ const maxLength = parseInt(maxLengthInput.value) || 4096;
303
+ const charCount = text.length;
304
+
305
+ charCountElement.textContent = charCount.toLocaleString();
306
+
307
+ // Update length status with better visual feedback
308
+ const statusElement = document.getElementById('length-status');
309
+ if (statusElement) {
310
+ const percentage = (charCount / maxLength) * 100;
311
+
312
+ if (charCount > maxLength) {
313
+ statusElement.innerHTML = '<span class="badge bg-danger"><i class="fas fa-exclamation-triangle me-1"></i>Exceeds limit</span>';
314
+ } else if (percentage > 80) {
315
+ statusElement.innerHTML = '<span class="badge bg-warning"><i class="fas fa-exclamation me-1"></i>Near limit</span>';
316
+ } else if (percentage > 50) {
317
+ statusElement.innerHTML = '<span class="badge bg-info"><i class="fas fa-info me-1"></i>Good</span>';
318
+ } else {
319
+ statusElement.innerHTML = '<span class="badge bg-success"><i class="fas fa-check me-1"></i>OK</span>';
320
+ }
321
+ }
322
+
323
+ updateGenerateButton();
324
+ updateAutoCombineStatus();
325
+ }
326
+
327
+ function updateGenerateButton() {
328
+ const text = document.getElementById('text-input').value;
329
+ const maxLength = parseInt(document.getElementById('max-length-input').value) || 4096;
330
+ const autoCombineCheck = document.getElementById('auto-combine-check');
331
+ const autoCombine = autoCombineCheck ? autoCombineCheck.checked : false;
332
+ const generateBtn = document.getElementById('generate-btn');
333
+
334
+ if (!generateBtn) {
335
+ console.warn('Generate button not found');
336
+ return;
337
+ }
338
+
339
+ const btnText = generateBtn.querySelector('.btn-text');
340
+
341
+ if (!btnText) {
342
+ console.warn('Button text element not found');
343
+ return;
344
+ }
345
+
346
+ if (text.length > maxLength && autoCombine) {
347
+ btnText.innerHTML = '<i class="fas fa-magic me-2"></i>Generate Speech (Auto-Combine)';
348
+ generateBtn.classList.add('btn-warning');
349
+ generateBtn.classList.remove('btn-primary');
350
+ } else {
351
+ btnText.innerHTML = '<i class="fas fa-magic me-2"></i>Generate Speech';
352
+ generateBtn.classList.add('btn-primary');
353
+ generateBtn.classList.remove('btn-warning');
354
+ }
355
+ }
356
+
357
+ async function validateText() {
358
+ const text = document.getElementById('text-input').value.trim();
359
+ const maxLength = parseInt(document.getElementById('max-length-input').value) || 4096;
360
+
361
+ if (!text) {
362
+ console.log('Please enter some text to validate');
363
+ return;
364
+ }
365
+
366
+ const validateBtn = document.getElementById('validate-text-btn');
367
+ setLoading(validateBtn, true);
368
+
369
+ try {
370
+ const response = await fetch('/api/validate-text', {
371
+ method: 'POST',
372
+ headers: { 'Content-Type': 'application/json' },
373
+ body: JSON.stringify({ text, max_length: maxLength })
374
+ });
375
+
376
+ const data = await response.json();
377
+ const resultDiv = document.getElementById('validation-result');
378
+
379
+ if (data.is_valid) {
380
+ resultDiv.innerHTML = `
381
+ <div class="alert alert-success fade-in">
382
+ <i class="fas fa-check-circle me-2"></i>
383
+ <strong>Text is valid!</strong> (${data.text_length.toLocaleString()} characters)
384
+ <div class="progress progress-custom mt-2">
385
+ <div class="progress-bar-custom" style="width: ${(data.text_length / data.max_length) * 100}%"></div>
386
+ </div>
387
+ </div>
388
+ `;
389
+ } else {
390
+ resultDiv.innerHTML = `
391
+ <div class="alert alert-warning fade-in">
392
+ <i class="fas fa-exclamation-triangle me-2"></i>
393
+ <strong>Text exceeds limit!</strong> (${data.text_length.toLocaleString()}/${data.max_length.toLocaleString()} characters)
394
+ <br><small class="mt-2 d-block">Suggested chunks: ${data.suggested_chunks}</small>
395
+ <div class="mt-3">
396
+ <strong>Preview of chunks:</strong>
397
+ <div class="mt-2">
398
+ ${data.chunk_preview.map((chunk, i) => `
399
+ <div class="border rounded p-2 mb-2 bg-light">
400
+ <small class="text-muted">Chunk ${i+1}:</small>
401
+ <div class="small">${chunk}</div>
402
+ </div>
403
+ `).join('')}
404
+ </div>
405
+
406
+ </div>
407
+ </div>
408
+ `;
409
+ }
410
+
411
+ resultDiv.classList.remove('d-none');
412
+ resultDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
413
+
414
+ } catch (error) {
415
+ console.error('Validation failed:', error);
416
+ console.log('Failed to validate text. Please try again.');
417
+ } finally {
418
+ setLoading(validateBtn, false);
419
+ }
420
+ }
421
+
422
+
423
+
424
+ function updateAutoCombineStatus() {
425
+ const autoCombineCheck = document.getElementById('auto-combine-check');
426
+ const statusBadge = document.getElementById('auto-combine-status');
427
+ const textInput = document.getElementById('text-input');
428
+ const maxLength = parseInt(document.getElementById('max-length-input').value) || 4096;
429
+
430
+ if (!autoCombineCheck || !statusBadge) return;
431
+
432
+ const isAutoCombineEnabled = autoCombineCheck.checked;
433
+ const textLength = textInput.value.length;
434
+ const isLongText = textLength > maxLength;
435
+
436
+ // Show/hide status badge
437
+ if (isAutoCombineEnabled && isLongText) {
438
+ statusBadge.classList.remove('d-none');
439
+ statusBadge.classList.add('bg-success');
440
+ statusBadge.classList.remove('bg-warning');
441
+ statusBadge.innerHTML = '<i class="fas fa-magic me-1"></i>Auto-combine enabled';
442
+ } else if (!isAutoCombineEnabled && isLongText) {
443
+ statusBadge.classList.remove('d-none');
444
+ statusBadge.classList.add('bg-warning');
445
+ statusBadge.classList.remove('bg-success');
446
+ statusBadge.innerHTML = '<i class="fas fa-exclamation-triangle me-1"></i>Long text detected';
447
+ } else {
448
+ statusBadge.classList.add('d-none');
449
+ }
450
+
451
+ // Remove the recursive call to updateCharCount() - this was causing infinite recursion
452
+ }
453
+
454
+ async function generateSpeech(event) {
455
+ console.log('generateSpeech function called');
456
+
457
+ // Prevent default form submission behavior
458
+ if (event) {
459
+ event.preventDefault();
460
+ event.stopPropagation();
461
+ }
462
+
463
+ const button = document.getElementById('generate-btn');
464
+ const audioResult = document.getElementById('audio-result');
465
+
466
+ // Get form data
467
+ const formData = getFormData();
468
+
469
+ if (!validateFormData(formData)) {
470
+ console.log('Form validation failed');
471
+ return false;
472
+ }
473
+
474
+ // Show loading state
475
+ setLoading(button, true);
476
+ clearResults();
477
+
478
+ try {
479
+ console.log('Starting speech generation...');
480
+ // Always use the unified endpoint with auto-combine
481
+ await generateUnifiedSpeech(formData);
482
+ console.log('Speech generation completed successfully');
483
+ } catch (error) {
484
+ console.error('Generation failed:', error);
485
+ console.log(`Failed to generate speech: ${error.message}`);
486
+ } finally {
487
+ setLoading(button, false);
488
+ }
489
+
490
+ return false; // Ensure form doesn't submit
491
+ }
492
+
493
+ function getFormData() {
494
+ return {
495
+ text: document.getElementById('text-input').value.trim(),
496
+ voice: document.getElementById('voice-select').value,
497
+ format: document.getElementById('format-select').value,
498
+ instructions: document.getElementById('instructions-input').value.trim(),
499
+ maxLength: parseInt(document.getElementById('max-length-input').value) || 4096,
500
+ validateLength: document.getElementById('validate-length-check').checked,
501
+ autoCombine: document.getElementById('auto-combine-check').checked,
502
+ apiKey: document.getElementById('api-key-input').value.trim()
503
+ };
504
+ }
505
+
506
+ function validateFormData(formData) {
507
+ if (!formData.text || !formData.voice || !formData.format) {
508
+ console.log('Please fill in all required fields');
509
+ return false;
510
+ }
511
+
512
+ if (formData.text.length > formData.maxLength && formData.validateLength && !formData.autoCombine) {
513
+ console.log(`Text is too long (${formData.text.length} characters). Enable auto-combine or reduce text length.`);
514
+ return false;
515
+ }
516
+
517
+ return true;
518
+ }
519
+
520
+ function clearResults() {
521
+ document.getElementById('audio-result').classList.add('d-none');
522
+ const batchResult = document.getElementById('batch-result');
523
+ if (batchResult) {
524
+ batchResult.classList.add('d-none');
525
+ }
526
+ document.getElementById('validation-result').classList.add('d-none');
527
+ }
528
+
529
+ // Utility functions
530
+ function setLoading(button, loading) {
531
+ if (loading) {
532
+ button.classList.add('loading');
533
+ button.disabled = true;
534
+ } else {
535
+ button.classList.remove('loading');
536
+ button.disabled = false;
537
+ }
538
+ }
539
+
540
+
541
+
542
+ // New unified function using OpenAI-compatible endpoint with auto-combine
543
+ async function generateUnifiedSpeech(formData) {
544
+ const audioResult = document.getElementById('audio-result');
545
+
546
+ // Prepare headers
547
+ const headers = { 'Content-Type': 'application/json' };
548
+
549
+ // Add API key if provided (OpenAI compatible format)
550
+ if (formData.apiKey) {
551
+ headers['Authorization'] = `Bearer ${formData.apiKey}`;
552
+ }
553
+
554
+ const response = await fetch('/v1/audio/speech', {
555
+ method: 'POST',
556
+ headers: headers,
557
+ body: JSON.stringify({
558
+ model: 'gpt-4o-mini-tts',
559
+ input: formData.text,
560
+ voice: formData.voice,
561
+ response_format: formData.format,
562
+ instructions: formData.instructions || undefined,
563
+ auto_combine: formData.autoCombine,
564
+ max_length: formData.maxLength
565
+ })
566
+ });
567
+
568
+ if (!response.ok) {
569
+ const errorData = await response.json();
570
+ const errorMessage = errorData.error?.message || errorData.error || `HTTP ${response.status}`;
571
+ throw new Error(errorMessage);
572
+ }
573
+
574
+ // Get audio data
575
+ const audioBlob = await response.blob();
576
+ currentAudioBlob = audioBlob;
577
+ currentFormat = formData.format;
578
+
579
+ // Create audio URL and setup player
580
+ const audioUrl = URL.createObjectURL(audioBlob);
581
+ const audioPlayer = document.getElementById('audio-player');
582
+ audioPlayer.src = audioUrl;
583
+
584
+ // Get response headers for enhanced display
585
+ const chunksCount = response.headers.get('X-Chunks-Combined') || '1';
586
+ const autoCombineUsed = response.headers.get('X-Auto-Combine') === 'true';
587
+ const originalLength = response.headers.get('X-Original-Text-Length');
588
+
589
+ // Use enhanced display function with new metadata
590
+ displayAudioResult(audioBlob, formData.format, formData.voice, formData.text, {
591
+ chunksCount,
592
+ autoCombineUsed,
593
+ originalLength
594
+ });
595
+
596
+ console.log('Speech generated successfully! Click play to listen.');
597
+ if (autoCombineUsed && chunksCount > 1) {
598
+ console.log(`Auto-combine feature combined ${chunksCount} chunks into a single audio file.`);
599
+ }
600
+
601
+ // Auto-play if user prefers
602
+ if (localStorage.getItem('autoPlay') === 'true') {
603
+ audioPlayer.play().catch(() => {
604
+ // Auto-play blocked, that's fine
605
+ });
606
+ }
607
+ }
608
+
609
+ // Legacy function for backward compatibility
610
+ async function generateSingleSpeech(formData) {
611
+ // Use the new unified function
612
+ await generateUnifiedSpeech(formData);
613
+ }
614
+
615
+
616
+
617
+
618
+
619
+ function downloadAudio() {
620
+ if (!currentAudioBlob) {
621
+ console.log('No audio to download');
622
+ return;
623
+ }
624
+
625
+ const url = URL.createObjectURL(currentAudioBlob);
626
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
627
+ downloadFromUrl(url, `ttsfm-speech-${timestamp}.${currentFormat}`);
628
+ URL.revokeObjectURL(url);
629
+ }
630
+
631
+
632
+
633
+ function downloadFromUrl(url, filename) {
634
+ const a = document.createElement('a');
635
+ a.href = url;
636
+ a.download = filename;
637
+ a.style.display = 'none';
638
+ document.body.appendChild(a);
639
+ a.click();
640
+ document.body.removeChild(a);
641
+ }
642
+
643
+ // New enhanced functions
644
+ function clearText() {
645
+ document.getElementById('text-input').value = '';
646
+ updateCharCount();
647
+ clearResults();
648
+ console.log('Text cleared successfully');
649
+ }
650
+
651
+ function loadRandomText() {
652
+ const randomTexts = [
653
+ // News & Information
654
+ "Breaking news: Scientists have discovered a revolutionary new method for generating incredibly natural synthetic speech using advanced neural networks and machine learning algorithms.",
655
+ "Weather update: Today will be partly cloudy with temperatures reaching 75 degrees Fahrenheit. Light winds from the southwest at 5 to 10 miles per hour.",
656
+ "Technology report: The latest advancements in artificial intelligence are revolutionizing how we interact with digital devices and services.",
657
+
658
+ // Educational & Informative
659
+ "The human brain contains approximately 86 billion neurons, each connected to thousands of others, creating a complex network that enables consciousness, memory, and thought.",
660
+ "Photosynthesis is the process by which plants convert sunlight, carbon dioxide, and water into glucose and oxygen, forming the foundation of most life on Earth.",
661
+ "The speed of light in a vacuum is exactly 299,792,458 meters per second, making it one of the fundamental constants of physics.",
662
+
663
+ // Creative & Storytelling
664
+ "Once upon a time, in a land far away, there lived a wise old wizard who could speak to the stars and understand their ancient secrets.",
665
+ "The mysterious lighthouse stood alone on the rocky cliff, its beacon cutting through the fog like a sword of light, guiding lost ships safely home.",
666
+ "In the depths of the enchanted forest, where sunbeams danced through emerald leaves, a young adventurer discovered a hidden path to destiny.",
667
+
668
+ // Business & Professional
669
+ "Our quarterly results demonstrate strong growth across all market segments, with revenue increasing by 23% compared to the same period last year.",
670
+ "The new product launch exceeded expectations, capturing 15% market share within the first six months and establishing our brand as an industry leader.",
671
+ "We are committed to sustainable business practices that benefit our customers, employees, and the environment for generations to come.",
672
+
673
+ // Technical & Programming
674
+ "The TTSFM package provides a comprehensive API for text-to-speech generation with support for multiple voices and audio formats.",
675
+ "Machine learning algorithms process vast amounts of data to identify patterns and make predictions with remarkable accuracy.",
676
+ "Cloud computing has transformed how businesses store, process, and access their data, enabling scalability and flexibility like never before.",
677
+
678
+ // Conversational & Casual
679
+ "Welcome to TTSFM! Experience the future of text-to-speech technology with our premium AI voices.",
680
+ "Good morning! Today is a beautiful day to learn something new and explore the possibilities of text-to-speech technology.",
681
+ "Have you ever wondered what it would be like if your computer could speak with perfect human-like intonation and emotion?"
682
+ ];
683
+
684
+ const randomText = randomTexts[Math.floor(Math.random() * randomTexts.length)];
685
+ document.getElementById('text-input').value = randomText;
686
+ updateCharCount();
687
+ console.log('Random text loaded successfully');
688
+ }
689
+
690
+
691
+
692
+ function resetForm() {
693
+ // Reset form to default values
694
+ document.getElementById('text-input').value = 'Welcome to TTSFM! Experience the future of text-to-speech technology with our premium AI voices. Generate natural, expressive speech for any application.';
695
+ document.getElementById('voice-select').value = 'alloy';
696
+ document.getElementById('format-select').value = 'mp3';
697
+ document.getElementById('instructions-input').value = '';
698
+ document.getElementById('max-length-input').value = '4096';
699
+ document.getElementById('validate-length-check').checked = true;
700
+ const autoCombineCheck = document.getElementById('auto-combine-check');
701
+ if (autoCombineCheck) {
702
+ autoCombineCheck.checked = true;
703
+ }
704
+
705
+ updateCharCount();
706
+ updateGenerateButton();
707
+ clearResults();
708
+ console.log('Form reset to default values');
709
+ }
710
+
711
+ function replayAudio() {
712
+ const audioPlayer = document.getElementById('audio-player');
713
+ if (audioPlayer && audioPlayer.src) {
714
+ audioPlayer.currentTime = 0;
715
+ audioPlayer.play().catch(() => {
716
+ console.log('Unable to replay audio. Please check your browser settings.');
717
+ });
718
+ }
719
+ }
720
+
721
+ function shareAudio() {
722
+ if (navigator.share && currentAudioBlob) {
723
+ const file = new File([currentAudioBlob], `ttsfm-speech.${currentFormat}`, {
724
+ type: `audio/${currentFormat}`
725
+ });
726
+
727
+ navigator.share({
728
+ title: 'TTSFM Generated Speech',
729
+ text: 'Check out this speech generated with TTSFM!',
730
+ files: [file]
731
+ }).catch(() => {
732
+ // Fallback to copying link
733
+ copyAudioLink();
734
+ });
735
+ } else {
736
+ copyAudioLink();
737
+ }
738
+ }
739
+
740
+ function copyAudioLink() {
741
+ const audioPlayer = document.getElementById('audio-player');
742
+ if (audioPlayer && audioPlayer.src) {
743
+ navigator.clipboard.writeText(audioPlayer.src).then(() => {
744
+ console.log('Audio link copied to clipboard!');
745
+ }).catch(() => {
746
+ console.log('Unable to copy link. Please try downloading the audio instead.');
747
+ });
748
+ }
749
+ }
750
+
751
+ function updateVoiceInfo() {
752
+ const voiceSelect = document.getElementById('voice-select');
753
+ const previewBtn = document.getElementById('preview-voice-btn');
754
+
755
+ if (voiceSelect.value) {
756
+ previewBtn.disabled = false;
757
+ previewBtn.onclick = () => previewVoice(voiceSelect.value);
758
+ } else {
759
+ previewBtn.disabled = true;
760
+ }
761
+ }
762
+
763
+ function updateFormatInfo() {
764
+ const formatSelect = document.getElementById('format-select');
765
+ const formatInfo = document.getElementById('format-info');
766
+
767
+ const formatDescriptions = {
768
+ 'mp3': '🎵 MP3 - Good quality, small file size. Best for web and general use.',
769
+ 'opus': '📻 OPUS - Excellent quality, small file size. Best for streaming and VoIP.',
770
+ 'aac': '📱 AAC - Good quality, medium file size. Best for Apple devices and streaming.',
771
+ 'flac': '💿 FLAC - Lossless quality, large file size. Best for archival and high-quality audio.',
772
+ 'wav': '🎧 WAV - Lossless quality, large file size. Best for professional audio production.',
773
+ 'pcm': '🔊 PCM - Raw audio data, large file size. Best for audio processing.'
774
+ };
775
+
776
+ if (formatInfo && formatSelect.value) {
777
+ formatInfo.textContent = formatDescriptions[formatSelect.value] || 'High-quality audio format';
778
+ }
779
+ }
780
+
781
+ function previewVoice(voiceId) {
782
+ // This would typically play a short preview of the voice
783
+ console.log(`Voice preview for ${voiceId} - Feature coming soon!`);
784
+ }
785
+
786
+ // Enhanced audio result display with auto-combine metadata
787
+ function displayAudioResult(audioBlob, format, voice, text, metadata = {}) {
788
+ const audioResult = document.getElementById('audio-result');
789
+ const audioPlayer = document.getElementById('audio-player');
790
+ const audioInfo = document.getElementById('audio-info');
791
+
792
+ // Create audio URL and setup player
793
+ const audioUrl = URL.createObjectURL(audioBlob);
794
+ audioPlayer.src = audioUrl;
795
+
796
+ // Update audio stats
797
+ const sizeKB = (audioBlob.size / 1024).toFixed(1);
798
+ document.getElementById('audio-size').textContent = `${sizeKB} KB`;
799
+ document.getElementById('audio-format').textContent = format.toUpperCase();
800
+ document.getElementById('audio-voice').textContent = voice.charAt(0).toUpperCase() + voice.slice(1);
801
+
802
+ // Update audio info safely without innerHTML
803
+ // Clear existing content
804
+ audioInfo.textContent = '';
805
+
806
+ // Create and append icon element
807
+ const icon = document.createElement('i');
808
+ icon.className = 'fas fa-check-circle text-success me-1';
809
+ audioInfo.appendChild(icon);
810
+
811
+ // Create info text with auto-combine details
812
+ let infoText = `Generated successfully • ${sizeKB} KB • ${format.toUpperCase()}`;
813
+
814
+ if (metadata.autoCombineUsed && metadata.chunksCount > 1) {
815
+ infoText += ` • Auto-combined ${metadata.chunksCount} chunks`;
816
+
817
+ // Add a special badge for auto-combine
818
+ const badge = document.createElement('span');
819
+ badge.className = 'badge bg-primary ms-2';
820
+ badge.innerHTML = '<i class="fas fa-magic me-1"></i>Auto-combined';
821
+ audioInfo.appendChild(document.createTextNode(infoText));
822
+ audioInfo.appendChild(badge);
823
+ } else {
824
+ // Create and append text content (safely escaped)
825
+ const textNode = document.createTextNode(infoText);
826
+ audioInfo.appendChild(textNode);
827
+ }
828
+
829
+ // Show result with animation
830
+ audioResult.classList.remove('d-none');
831
+ audioResult.classList.add('fade-in');
832
+
833
+ // Update duration when metadata loads
834
+ audioPlayer.addEventListener('loadedmetadata', function() {
835
+ const duration = Math.round(audioPlayer.duration);
836
+ document.getElementById('audio-duration').textContent = `${duration}s`;
837
+ }, { once: true });
838
+
839
+ // Scroll to result
840
+ audioResult.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
841
+ }
842
+
843
+ // API Key visibility toggle function
844
+ function toggleApiKeyVisibility() {
845
+ const apiKeyInput = document.getElementById('api-key-input');
846
+ const eyeIcon = document.getElementById('api-key-eye-icon');
847
+
848
+ if (apiKeyInput.type === 'password') {
849
+ apiKeyInput.type = 'text';
850
+ eyeIcon.className = 'fas fa-eye-slash';
851
+ } else {
852
+ apiKeyInput.type = 'password';
853
+ eyeIcon.className = 'fas fa-eye';
854
+ }
855
+ }
856
+
857
+ // Export functions for use in HTML
858
+ window.clearText = clearText;
859
+ window.loadRandomText = loadRandomText;
860
+ window.resetForm = resetForm;
861
+ window.toggleApiKeyVisibility = toggleApiKeyVisibility;
static/js/websocket-tts.js ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * WebSocket TTS Streaming Client
3
+ *
4
+ * Because apparently HTTP requests are so 2023.
5
+ * Now we need real-time streaming for everything.
6
+ */
7
+
8
+ class WebSocketTTSClient {
9
+ constructor(options = {}) {
10
+ this.socketUrl = options.socketUrl || window.location.origin;
11
+ this.socket = null;
12
+ this.activeRequests = new Map();
13
+ this.reconnectAttempts = 0;
14
+ this.maxReconnectAttempts = options.maxReconnectAttempts || 5;
15
+ this.reconnectDelay = options.reconnectDelay || 1000;
16
+ this.debug = options.debug || false;
17
+
18
+ // Audio context for seamless playback
19
+ this.audioContext = null;
20
+ this.audioQueue = new Map(); // request_id -> audio chunks
21
+
22
+ // Event handlers
23
+ this.onConnect = options.onConnect || (() => {});
24
+ this.onDisconnect = options.onDisconnect || (() => {});
25
+ this.onError = options.onError || ((error) => console.error('WebSocket error:', error));
26
+
27
+ // Initialize
28
+ this.connect();
29
+ }
30
+
31
+ connect() {
32
+ if (this.socket && this.socket.connected) {
33
+ this.log('Already connected');
34
+ return;
35
+ }
36
+
37
+ this.log('Connecting to WebSocket server...');
38
+
39
+ // Initialize Socket.IO connection
40
+ this.socket = io(this.socketUrl, {
41
+ transports: ['websocket', 'polling'],
42
+ reconnection: true,
43
+ reconnectionAttempts: this.maxReconnectAttempts,
44
+ reconnectionDelay: this.reconnectDelay
45
+ });
46
+
47
+ // Set up event handlers
48
+ this.setupEventHandlers();
49
+ }
50
+
51
+ setupEventHandlers() {
52
+ // Connection events
53
+ this.socket.on('connect', () => {
54
+ this.log('Connected to WebSocket server');
55
+ this.reconnectAttempts = 0;
56
+ this.onConnect();
57
+ });
58
+
59
+ this.socket.on('disconnect', (reason) => {
60
+ this.log('Disconnected from WebSocket server:', reason);
61
+ this.onDisconnect(reason);
62
+ });
63
+
64
+ this.socket.on('connect_error', (error) => {
65
+ this.log('Connection error:', error);
66
+ this.reconnectAttempts++;
67
+ this.onError({
68
+ type: 'connection_error',
69
+ message: error.message,
70
+ attempts: this.reconnectAttempts
71
+ });
72
+ });
73
+
74
+ // TTS streaming events
75
+ this.socket.on('connected', (data) => {
76
+ this.log('Session established:', data.session_id);
77
+ });
78
+
79
+ this.socket.on('stream_started', (data) => {
80
+ this.log('Stream started:', data.request_id);
81
+ const request = this.activeRequests.get(data.request_id);
82
+ if (request && request.onStart) {
83
+ request.onStart(data);
84
+ }
85
+ });
86
+
87
+ this.socket.on('audio_chunk', (data) => {
88
+ this.handleAudioChunk(data);
89
+ });
90
+
91
+ this.socket.on('stream_progress', (data) => {
92
+ this.handleProgress(data);
93
+ });
94
+
95
+ this.socket.on('stream_complete', (data) => {
96
+ this.handleStreamComplete(data);
97
+ });
98
+
99
+ this.socket.on('stream_error', (data) => {
100
+ this.handleStreamError(data);
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Generate speech with real-time streaming
106
+ */
107
+ generateSpeech(text, options = {}) {
108
+ return new Promise((resolve, reject) => {
109
+ if (!this.socket || !this.socket.connected) {
110
+ reject(new Error('WebSocket not connected'));
111
+ return;
112
+ }
113
+
114
+ const requestId = this.generateRequestId();
115
+ const audioChunks = [];
116
+
117
+ // Store request info
118
+ this.activeRequests.set(requestId, {
119
+ resolve,
120
+ reject,
121
+ audioChunks,
122
+ options,
123
+ startTime: Date.now(),
124
+ onStart: options.onStart,
125
+ onProgress: options.onProgress,
126
+ onChunk: options.onChunk,
127
+ onComplete: options.onComplete,
128
+ onError: options.onError
129
+ });
130
+
131
+ // Initialize audio queue for this request
132
+ this.audioQueue.set(requestId, []);
133
+
134
+ // Emit generation request
135
+ this.socket.emit('generate_stream', {
136
+ request_id: requestId,
137
+ text: text,
138
+ voice: options.voice || 'alloy',
139
+ format: options.format || 'mp3',
140
+ chunk_size: options.chunkSize || 1024
141
+ });
142
+
143
+ this.log('Requested speech generation:', requestId);
144
+ });
145
+ }
146
+
147
+ handleAudioChunk(data) {
148
+ const request = this.activeRequests.get(data.request_id);
149
+ if (!request) {
150
+ this.log('Received chunk for unknown request:', data.request_id);
151
+ return;
152
+ }
153
+
154
+ // Convert hex string back to binary
155
+ const audioData = this.hexToArrayBuffer(data.audio_data);
156
+
157
+ // Store chunk
158
+ request.audioChunks.push({
159
+ index: data.chunk_index,
160
+ data: audioData,
161
+ duration: data.duration,
162
+ format: data.format
163
+ });
164
+
165
+ // Add to audio queue for streaming playback
166
+ const queue = this.audioQueue.get(data.request_id);
167
+ if (queue) {
168
+ queue.push(audioData);
169
+ }
170
+
171
+ // Call chunk handler if provided
172
+ if (request.onChunk) {
173
+ request.onChunk({
174
+ chunkIndex: data.chunk_index,
175
+ totalChunks: data.total_chunks,
176
+ audioData: audioData,
177
+ duration: data.duration,
178
+ text: data.chunk_text
179
+ });
180
+ }
181
+
182
+ this.log(`Received chunk ${data.chunk_index + 1}/${data.total_chunks} for request ${data.request_id}`);
183
+ }
184
+
185
+ handleProgress(data) {
186
+ const request = this.activeRequests.get(data.request_id);
187
+ if (request && request.onProgress) {
188
+ request.onProgress({
189
+ progress: data.progress,
190
+ chunksCompleted: data.chunks_completed,
191
+ totalChunks: data.total_chunks,
192
+ status: data.status
193
+ });
194
+ }
195
+ }
196
+
197
+ handleStreamComplete(data) {
198
+ const request = this.activeRequests.get(data.request_id);
199
+ if (!request) {
200
+ this.log('Completion for unknown request:', data.request_id);
201
+ return;
202
+ }
203
+
204
+ // Sort chunks by index
205
+ request.audioChunks.sort((a, b) => a.index - b.index);
206
+
207
+ // Combine all audio chunks
208
+ const combinedAudio = this.combineAudioChunks(request.audioChunks);
209
+
210
+ const result = {
211
+ requestId: data.request_id,
212
+ audioData: combinedAudio,
213
+ chunks: request.audioChunks,
214
+ duration: request.audioChunks.reduce((sum, chunk) => sum + chunk.duration, 0),
215
+ generationTime: Date.now() - request.startTime,
216
+ format: request.audioChunks[0]?.format || 'mp3'
217
+ };
218
+
219
+ // Call complete handler
220
+ if (request.onComplete) {
221
+ request.onComplete(result);
222
+ }
223
+
224
+ // Resolve promise
225
+ request.resolve(result);
226
+
227
+ // Cleanup
228
+ this.activeRequests.delete(data.request_id);
229
+ this.audioQueue.delete(data.request_id);
230
+
231
+ this.log('Stream completed:', data.request_id);
232
+ }
233
+
234
+ handleStreamError(data) {
235
+ const request = this.activeRequests.get(data.request_id);
236
+ if (!request) {
237
+ this.log('Error for unknown request:', data.request_id);
238
+ return;
239
+ }
240
+
241
+ const error = new Error(data.error);
242
+ error.requestId = data.request_id;
243
+ error.timestamp = data.timestamp;
244
+
245
+ // Call error handler
246
+ if (request.onError) {
247
+ request.onError(error);
248
+ }
249
+
250
+ // Reject promise
251
+ request.reject(error);
252
+
253
+ // Cleanup
254
+ this.activeRequests.delete(data.request_id);
255
+ this.audioQueue.delete(data.request_id);
256
+
257
+ this.log('Stream error:', data.request_id, data.error);
258
+ }
259
+
260
+ /**
261
+ * Cancel an active stream
262
+ */
263
+ cancelStream(requestId) {
264
+ if (!this.socket || !this.socket.connected) {
265
+ throw new Error('WebSocket not connected');
266
+ }
267
+
268
+ this.socket.emit('cancel_stream', { request_id: requestId });
269
+
270
+ // Clean up local state
271
+ const request = this.activeRequests.get(requestId);
272
+ if (request) {
273
+ request.reject(new Error('Stream cancelled by user'));
274
+ this.activeRequests.delete(requestId);
275
+ this.audioQueue.delete(requestId);
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Combine audio chunks into a single buffer
281
+ */
282
+ combineAudioChunks(chunks) {
283
+ if (chunks.length === 0) return new ArrayBuffer(0);
284
+
285
+ // Calculate total size
286
+ const totalSize = chunks.reduce((sum, chunk) => sum + chunk.data.byteLength, 0);
287
+
288
+ // Create combined buffer
289
+ const combined = new ArrayBuffer(totalSize);
290
+ const view = new Uint8Array(combined);
291
+
292
+ let offset = 0;
293
+ for (const chunk of chunks) {
294
+ view.set(new Uint8Array(chunk.data), offset);
295
+ offset += chunk.data.byteLength;
296
+ }
297
+
298
+ return combined;
299
+ }
300
+
301
+ /**
302
+ * Play audio directly (experimental streaming playback)
303
+ */
304
+ async playAudioStream(requestId) {
305
+ if (!this.audioContext) {
306
+ this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
307
+ }
308
+
309
+ const queue = this.audioQueue.get(requestId);
310
+ if (!queue) {
311
+ throw new Error('No audio queue found for request');
312
+ }
313
+
314
+ // This is a simplified version - real implementation would need
315
+ // proper audio decoding and buffering for seamless playback
316
+ this.log('Streaming audio playback not fully implemented yet');
317
+ }
318
+
319
+ /**
320
+ * Utility functions
321
+ */
322
+ hexToArrayBuffer(hex) {
323
+ const bytes = new Uint8Array(hex.length / 2);
324
+ for (let i = 0; i < hex.length; i += 2) {
325
+ bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
326
+ }
327
+ return bytes.buffer;
328
+ }
329
+
330
+ generateRequestId() {
331
+ return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
332
+ }
333
+
334
+ log(...args) {
335
+ if (this.debug) {
336
+ console.log('[WebSocketTTS]', ...args);
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Get connection status
342
+ */
343
+ isConnected() {
344
+ return this.socket && this.socket.connected;
345
+ }
346
+
347
+ /**
348
+ * Disconnect from server
349
+ */
350
+ disconnect() {
351
+ if (this.socket) {
352
+ this.socket.disconnect();
353
+ this.socket = null;
354
+ }
355
+
356
+ // Clear all active requests
357
+ for (const [requestId, request] of this.activeRequests) {
358
+ request.reject(new Error('Client disconnected'));
359
+ }
360
+ this.activeRequests.clear();
361
+ this.audioQueue.clear();
362
+ }
363
+ }
364
+
365
+ // Export for use
366
+ window.WebSocketTTSClient = WebSocketTTSClient;
templates/base.html ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="{{ get_locale() }}">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{% block title %}TTSFM - {{ _('nav.home') }}{% endblock %}</title>
7
+
8
+ <!-- Bootstrap CSS -->
9
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
10
+
11
+ <!-- Font Awesome -->
12
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
13
+
14
+ <!-- Google Fonts -->
15
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
16
+
17
+ <!-- Custom CSS -->
18
+ <link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
19
+
20
+ <!-- Additional Performance Optimizations -->
21
+ <link rel="preconnect" href="https://fonts.googleapis.com">
22
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
23
+
24
+ <!-- Favicon -->
25
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎤</text></svg>">
26
+
27
+ <!-- Meta tags for better SEO and social sharing -->
28
+ <meta name="description" content="TTSFM - A Python client for text-to-speech APIs. Simple to use with support for multiple voices and audio formats.">
29
+ <meta name="keywords" content="text-to-speech, TTS, python, API, voice synthesis, audio generation">
30
+ <meta name="author" content="TTSFM">
31
+
32
+ <!-- Open Graph / Facebook -->
33
+ <meta property="og:type" content="website">
34
+ <meta property="og:url" content="{{ request.url }}">
35
+ <meta property="og:title" content="{% block og_title %}TTSFM - Python Text-to-Speech Client{% endblock %}">
36
+ <meta property="og:description" content="A Python client for text-to-speech APIs. Simple to use with support for multiple voices and audio formats.">
37
+
38
+ <!-- Twitter -->
39
+ <meta property="twitter:card" content="summary">
40
+ <meta property="twitter:url" content="{{ request.url }}">
41
+ <meta property="twitter:title" content="{% block twitter_title %}TTSFM - Python Text-to-Speech Client{% endblock %}">
42
+ <meta property="twitter:description" content="A Python client for text-to-speech APIs. Simple to use with support for multiple voices and audio formats.">
43
+
44
+ {% block extra_css %}{% endblock %}
45
+
46
+ <!-- Language button styling -->
47
+ <style>
48
+ /* Language dropdown button styling */
49
+ #languageDropdown {
50
+ border-color: #6c757d;
51
+ color: #6c757d;
52
+ transition: all 0.2s ease-in-out;
53
+ font-size: 0.875rem;
54
+ }
55
+
56
+ #languageDropdown:hover {
57
+ border-color: #495057;
58
+ color: #495057;
59
+ background-color: #f8f9fa;
60
+ }
61
+
62
+ #languageDropdown:focus {
63
+ box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.25);
64
+ }
65
+
66
+ /* Responsive language button */
67
+ @media (max-width: 576px) {
68
+ #languageDropdown {
69
+ font-size: 0.75rem;
70
+ padding: 0.25rem 0.5rem;
71
+ }
72
+ }
73
+
74
+ /* Ensure consistent button heights */
75
+ .navbar-nav .btn {
76
+ display: inline-flex;
77
+ align-items: center;
78
+ }
79
+ </style>
80
+ </head>
81
+ <body>
82
+ <!-- Skip to content link for accessibility -->
83
+ <a href="#main-content" class="skip-link">Skip to main content</a>
84
+
85
+ <!-- Clean Navigation -->
86
+ <nav class="navbar navbar-expand-lg fixed-top" style="background-color: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); border-bottom: 1px solid #e5e7eb;">
87
+ <div class="container">
88
+ <a class="navbar-brand" href="{{ url_for('index') }}">
89
+ <i class="fas fa-microphone-alt me-2"></i>
90
+ <span class="fw-bold">TTSFM</span>
91
+ <span class="badge bg-primary ms-2 small">v3.2.2</span>
92
+ </a>
93
+
94
+ <button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
95
+ <span class="navbar-toggler-icon"></span>
96
+ </button>
97
+
98
+ <div class="collapse navbar-collapse" id="navbarNav">
99
+ <ul class="navbar-nav me-auto">
100
+ <li class="nav-item">
101
+ <a class="nav-link" href="{{ url_for('index') }}" aria-label="{{ _('nav.home') }}">
102
+ <i class="fas fa-home me-1"></i>{{ _('nav.home') }}
103
+ </a>
104
+ </li>
105
+ <li class="nav-item">
106
+ <a class="nav-link" href="{{ url_for('playground') }}" aria-label="{{ _('nav.playground') }}">
107
+ <i class="fas fa-play me-1"></i>{{ _('nav.playground') }}
108
+ </a>
109
+ </li>
110
+ <li class="nav-item">
111
+ <a class="nav-link" href="{{ url_for('docs') }}" aria-label="{{ _('nav.documentation') }}">
112
+ <i class="fas fa-book me-1"></i>{{ _('nav.documentation') }}
113
+ </a>
114
+ </li>
115
+ </ul>
116
+
117
+ <ul class="navbar-nav">
118
+ <li class="nav-item">
119
+ <span class="navbar-text d-flex align-items-center">
120
+ <span id="status-indicator" class="status-indicator status-offline" aria-hidden="true"></span>
121
+ <span id="status-text" class="small">{{ _('nav.status_checking') }}</span>
122
+ </span>
123
+ </li>
124
+ <li class="nav-item dropdown ms-3">
125
+ <button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" id="languageDropdown" data-bs-toggle="dropdown" aria-expanded="false" title="{{ _('common.language') }}">
126
+ {% if get_locale() == 'zh' %}🇨🇳 中文{% else %}🇺🇸 English{% endif %}
127
+ </button>
128
+ <ul class="dropdown-menu" aria-labelledby="languageDropdown">
129
+ {% for lang_code, lang_name in get_supported_languages().items() %}
130
+ <li>
131
+ <a class="dropdown-item{% if get_locale() == lang_code %} active{% endif %}"
132
+ href="{{ url_for('set_language', lang_code=lang_code) }}">
133
+ {% if lang_code == 'en' %}🇺🇸{% elif lang_code == 'zh' %}🇨🇳{% endif %} {{ lang_name }}
134
+ </a>
135
+ </li>
136
+ {% endfor %}
137
+ </ul>
138
+ </li>
139
+ <li class="nav-item ms-3">
140
+ <a class="btn btn-outline-primary btn-sm" href="https://github.com/dbccccccc/ttsfm" target="_blank" rel="noopener noreferrer" aria-label="{{ _('nav.github') }}">
141
+ <i class="fab fa-github me-1"></i>{{ _('nav.github') }}
142
+ </a>
143
+ </li>
144
+ </ul>
145
+ </div>
146
+ </div>
147
+ </nav>
148
+
149
+ <!-- Main Content -->
150
+ <main id="main-content" style="padding-top: 76px;">
151
+ {% block content %}{% endblock %}
152
+ </main>
153
+
154
+ <!-- Simplified Footer -->
155
+ <footer class="footer py-3" style="background-color: #f9fafb; border-top: 1px solid #e5e7eb;" role="contentinfo">
156
+ <div class="container">
157
+ <div class="row align-items-center">
158
+ <div class="col-md-6">
159
+ <div class="d-flex align-items-center">
160
+ <i class="fas fa-microphone-alt me-2 text-primary"></i>
161
+ <strong class="text-dark">TTSFM</strong>
162
+ <span class="ms-2 text-muted">v3.2.2</span>
163
+ </div>
164
+ </div>
165
+ <div class="col-md-6 text-md-end">
166
+ <small class="text-muted">
167
+ {{ _('home.footer_copyright') }} •
168
+ <a href="{{ url_for('docs') }}" class="text-decoration-none text-muted">{{ _('nav.documentation') }}</a> •
169
+ <a href="https://github.com/dbccccccc/ttsfm" class="text-decoration-none text-muted" target="_blank">{{ _('nav.github') }}</a>
170
+ </small>
171
+ </div>
172
+ </div>
173
+ </div>
174
+ </footer>
175
+
176
+ <!-- Bootstrap JS -->
177
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
178
+
179
+ <!-- Internationalization Support -->
180
+ <script src="{{ url_for('static', filename='js/i18n.js') }}"></script>
181
+
182
+ <!-- Enhanced Common JavaScript -->
183
+ <script>
184
+ // Enhanced service status checking
185
+ async function checkStatus() {
186
+ try {
187
+ const response = await fetch('/api/health');
188
+ const data = await response.json();
189
+
190
+ const indicator = document.getElementById('status-indicator');
191
+ const text = document.getElementById('status-text');
192
+
193
+ if (response.ok && data.status === 'healthy') {
194
+ indicator.className = 'status-indicator status-online';
195
+ text.textContent = '{{ _("nav.status_online") }}';
196
+ } else {
197
+ indicator.className = 'status-indicator status-offline';
198
+ text.textContent = '{{ _("nav.status_offline") }}';
199
+ }
200
+ } catch (error) {
201
+ const indicator = document.getElementById('status-indicator');
202
+ const text = document.getElementById('status-text');
203
+ indicator.className = 'status-indicator status-offline';
204
+ text.textContent = '{{ _("nav.status_offline") }}';
205
+ }
206
+ }
207
+
208
+ // Enhanced page initialization
209
+ document.addEventListener('DOMContentLoaded', function() {
210
+ // Check status immediately and periodically
211
+ checkStatus();
212
+ setInterval(checkStatus, 30000); // Check every 30 seconds
213
+
214
+ // Initialize tooltips
215
+ if (typeof bootstrap !== 'undefined') {
216
+ const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
217
+ tooltipTriggerList.map(function (tooltipTriggerEl) {
218
+ return new bootstrap.Tooltip(tooltipTriggerEl);
219
+ });
220
+ }
221
+
222
+ // Add smooth scrolling for anchor links
223
+ document.querySelectorAll('a[href^="#"]').forEach(anchor => {
224
+ anchor.addEventListener('click', function (e) {
225
+ const target = document.querySelector(this.getAttribute('href'));
226
+ if (target) {
227
+ e.preventDefault();
228
+ target.scrollIntoView({
229
+ behavior: 'smooth',
230
+ block: 'start'
231
+ });
232
+ }
233
+ });
234
+ });
235
+
236
+ // Add fade-in animation to main content
237
+ const mainContent = document.querySelector('main');
238
+ if (mainContent) {
239
+ mainContent.classList.add('fade-in');
240
+ }
241
+
242
+ // Add loading states to external links
243
+ document.querySelectorAll('a[target="_blank"]').forEach(link => {
244
+ link.addEventListener('click', function() {
245
+ this.style.opacity = '0.7';
246
+ setTimeout(() => {
247
+ this.style.opacity = '1';
248
+ }, 1000);
249
+ });
250
+ });
251
+ });
252
+
253
+ // Enhanced utility function to show loading state
254
+ function setLoading(button, loading) {
255
+ if (loading) {
256
+ button.classList.add('loading');
257
+ button.disabled = true;
258
+ button.style.cursor = 'wait';
259
+ } else {
260
+ button.classList.remove('loading');
261
+ button.disabled = false;
262
+ button.style.cursor = 'pointer';
263
+ }
264
+ }
265
+
266
+ // Enhanced utility function to show alerts
267
+ function showAlert(message, type = 'info', duration = 5000) {
268
+ const alertDiv = document.createElement('div');
269
+ alertDiv.className = `alert alert-${type} alert-dismissible fade show fade-in`;
270
+ alertDiv.style.position = 'relative';
271
+ alertDiv.style.zIndex = '1050';
272
+ alertDiv.innerHTML = `
273
+ <i class="fas fa-${getAlertIcon(type)} me-2"></i>
274
+ ${message}
275
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
276
+ `;
277
+
278
+ // Find the best container to insert the alert
279
+ const container = document.querySelector('main .container') || document.querySelector('.container') || document.body;
280
+ if (container) {
281
+ container.insertBefore(alertDiv, container.firstChild);
282
+
283
+ // Auto-dismiss after specified duration
284
+ setTimeout(() => {
285
+ if (alertDiv.parentNode) {
286
+ alertDiv.classList.remove('show');
287
+ setTimeout(() => {
288
+ if (alertDiv.parentNode) {
289
+ alertDiv.remove();
290
+ }
291
+ }, 150);
292
+ }
293
+ }, duration);
294
+
295
+ // Scroll to alert if it's not visible
296
+ alertDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
297
+ }
298
+ }
299
+
300
+ // Helper function to get appropriate icon for alert type
301
+ function getAlertIcon(type) {
302
+ const icons = {
303
+ 'success': 'check-circle',
304
+ 'danger': 'exclamation-triangle',
305
+ 'warning': 'exclamation-triangle',
306
+ 'info': 'info-circle',
307
+ 'primary': 'info-circle'
308
+ };
309
+ return icons[type] || 'info-circle';
310
+ }
311
+
312
+ // Enhanced error handling for fetch requests
313
+ async function safeFetch(url, options = {}) {
314
+ try {
315
+ const response = await fetch(url, options);
316
+ if (!response.ok) {
317
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
318
+ }
319
+ return response;
320
+ } catch (error) {
321
+ console.error('Fetch error:', error);
322
+ showAlert(`Network error: ${error.message}`, 'danger');
323
+ throw error;
324
+ }
325
+ }
326
+
327
+ // Performance monitoring
328
+ window.addEventListener('load', function() {
329
+ // Log page load time
330
+ const loadTime = performance.now();
331
+ console.log(`Page loaded in ${Math.round(loadTime)}ms`);
332
+
333
+ // Check for slow loading resources
334
+ if (loadTime > 3000) {
335
+ console.warn('Page load time is slow. Consider optimizing resources.');
336
+ }
337
+ });
338
+
339
+ // Keyboard shortcuts
340
+ document.addEventListener('keydown', function(e) {
341
+ // Alt + H for home
342
+ if (e.altKey && e.key === 'h') {
343
+ e.preventDefault();
344
+ window.location.href = '{{ url_for("index") }}';
345
+ }
346
+
347
+ // Alt + P for playground
348
+ if (e.altKey && e.key === 'p') {
349
+ e.preventDefault();
350
+ window.location.href = '{{ url_for("playground") }}';
351
+ }
352
+
353
+ // Alt + D for docs
354
+ if (e.altKey && e.key === 'd') {
355
+ e.preventDefault();
356
+ window.location.href = '{{ url_for("docs") }}';
357
+ }
358
+ });
359
+ </script>
360
+
361
+ {% block extra_js %}{% endblock %}
362
+ </body>
363
+ </html>
templates/docs.html ADDED
@@ -0,0 +1,734 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}TTSFM {{ _('docs.title') }}{% endblock %}
4
+
5
+ {% block extra_css %}
6
+ <style>
7
+ .code-block {
8
+ background-color: #f8f9fa;
9
+ border: 1px solid #e9ecef;
10
+ border-radius: 0.375rem;
11
+ padding: 1rem;
12
+ margin: 1rem 0;
13
+ overflow-x: auto;
14
+ }
15
+
16
+ .endpoint-card {
17
+ border-left: 4px solid #007bff;
18
+ margin-bottom: 2rem;
19
+ }
20
+
21
+ .method-badge {
22
+ font-size: 0.75rem;
23
+ padding: 0.25rem 0.5rem;
24
+ border-radius: 0.25rem;
25
+ font-weight: bold;
26
+ margin-right: 0.5rem;
27
+ }
28
+
29
+ .method-get { background-color: #28a745; color: white; }
30
+ .method-post { background-color: #007bff; color: white; }
31
+ .method-put { background-color: #ffc107; color: black; }
32
+ .method-delete { background-color: #dc3545; color: white; }
33
+
34
+ .response-example {
35
+ background-color: #f1f3f4;
36
+ border-radius: 0.375rem;
37
+ padding: 1rem;
38
+ margin-top: 1rem;
39
+ }
40
+
41
+ .toc {
42
+ position: sticky;
43
+ top: 2rem;
44
+ max-height: calc(100vh - 4rem);
45
+ overflow-y: auto;
46
+ }
47
+
48
+ .toc a {
49
+ color: #6c757d;
50
+ text-decoration: none;
51
+ display: block;
52
+ padding: 0.25rem 0;
53
+ border-left: 2px solid transparent;
54
+ padding-left: 1rem;
55
+ }
56
+
57
+ .toc a:hover, .toc a.active {
58
+ color: #007bff;
59
+ border-left-color: #007bff;
60
+ }
61
+ </style>
62
+ {% endblock %}
63
+
64
+ {% block content %}
65
+ <div class="container py-5">
66
+ <div class="row">
67
+ <div class="col-12 text-center mb-5">
68
+ <h1 class="display-4 fw-bold">
69
+ <i class="fas fa-book me-3 text-primary"></i>{{ _('docs.title') }}
70
+ </h1>
71
+ <p class="lead text-muted">
72
+ {{ _('docs.subtitle') }}
73
+ </p>
74
+ </div>
75
+ </div>
76
+
77
+ <div class="row">
78
+ <!-- Table of Contents -->
79
+ <div class="col-lg-3">
80
+ <div class="toc">
81
+ <h5 class="fw-bold mb-3">{{ _('docs.contents') }}</h5>
82
+ <a href="#overview">{{ _('docs.overview') }}</a>
83
+ <a href="#authentication">{{ _('docs.authentication') }}</a>
84
+ <a href="#text-validation">{{ _('docs.text_validation') }}</a>
85
+ <a href="#endpoints">{{ _('docs.endpoints') }}</a>
86
+ <a href="#voices">{{ _('docs.voices') }}</a>
87
+ <a href="#formats">{{ _('docs.formats') }}</a>
88
+ <a href="#generate">{{ _('docs.generate') }}</a>
89
+ <a href="#combined">{{ _('docs.combined') }}</a>
90
+ <a href="#status">{{ _('docs.status') }}</a>
91
+ <a href="#errors">{{ _('docs.errors') }}</a>
92
+ <a href="#examples">{{ _('docs.examples') }}</a>
93
+ <a href="#python-package">{{ _('docs.python_package') }}</a>
94
+ <a href="#websocket">WebSocket Streaming</a>
95
+ </div>
96
+ </div>
97
+
98
+ <!-- Documentation Content -->
99
+ <div class="col-lg-9">
100
+ <!-- Overview -->
101
+ <section id="overview" class="mb-5">
102
+ <h2 class="fw-bold mb-3">{{ _('docs.overview_title') }}</h2>
103
+ <p>
104
+ {{ _('docs.overview_desc') }}
105
+ </p>
106
+
107
+ <div class="alert alert-info">
108
+ <i class="fas fa-info-circle me-2"></i>
109
+ <strong>{{ _('docs.base_url') }}</strong> <code>{{ request.url_root }}api/</code>
110
+ </div>
111
+
112
+ <h4>{{ _('docs.key_features') }}</h4>
113
+ <ul>
114
+ <li><strong>🎤 {{ _('docs.feature_voices') }}</strong></li>
115
+ <li><strong>🎵 {{ _('docs.feature_formats') }}</strong></li>
116
+ <li><strong>🤖 {{ _('docs.feature_openai') }}</strong></li>
117
+ <li><strong>✨ {{ _('docs.feature_auto_combine') }}</strong></li>
118
+ <li><strong>📊 {{ _('docs.feature_validation') }}</strong></li>
119
+ <li><strong>📈 {{ _('docs.feature_monitoring') }}</strong></li>
120
+ </ul>
121
+
122
+ <div class="alert alert-success">
123
+ <i class="fas fa-star me-2"></i>
124
+ <strong>{{ _('docs.new_version') }}</strong> {{ _('docs.new_version_desc') }}
125
+ </div>
126
+ </section>
127
+
128
+ <!-- Authentication -->
129
+ <section id="authentication" class="mb-5">
130
+ <h2 class="fw-bold mb-3">{{ _('docs.authentication_title') }}</h2>
131
+ <p>
132
+ {{ _('docs.authentication_desc') }}
133
+ </p>
134
+
135
+ <div class="code-block">
136
+ <pre><code>Authorization: Bearer YOUR_API_KEY</code></pre>
137
+ </div>
138
+ </section>
139
+
140
+ <!-- Text Validation -->
141
+ <section id="text-validation" class="mb-5">
142
+ <h2 class="fw-bold mb-3">{{ _('docs.text_validation_title') }}</h2>
143
+ <p>
144
+ {{ _('docs.text_validation_desc') }}
145
+ </p>
146
+
147
+ <div class="alert alert-warning">
148
+ <i class="fas fa-exclamation-triangle me-2"></i>
149
+ <strong>{{ _('docs.important') }}</strong> {{ _('docs.text_validation_warning') }}
150
+ </div>
151
+
152
+ <h4>{{ _('docs.validation_options') }}</h4>
153
+ <ul>
154
+ <li><code>max_length</code>: {{ _('docs.max_length_option') }}</li>
155
+ <li><code>validate_length</code>: {{ _('docs.validate_length_option') }}</li>
156
+ <li><code>preserve_words</code>: {{ _('docs.preserve_words_option') }}</li>
157
+ </ul>
158
+ </section>
159
+
160
+ <!-- API Endpoints -->
161
+ <section id="endpoints" class="mb-5">
162
+ <h2 class="fw-bold mb-3">{{ _('docs.endpoints_title') }}</h2>
163
+
164
+ <!-- Voices Endpoint -->
165
+ <div class="card endpoint-card" id="voices">
166
+ <div class="card-body">
167
+ <h4 class="card-title">
168
+ <span class="method-badge method-get">GET</span>
169
+ /api/voices
170
+ </h4>
171
+ <p class="card-text">{{ _('docs.get_voices_desc') }}</p>
172
+
173
+ <h6>{{ _('docs.response_example') }}</h6>
174
+ <div class="response-example">
175
+ <pre><code>{
176
+ "voices": [
177
+ {
178
+ "id": "alloy",
179
+ "name": "Alloy",
180
+ "description": "Alloy voice"
181
+ },
182
+ {
183
+ "id": "echo",
184
+ "name": "Echo",
185
+ "description": "Echo voice"
186
+ }
187
+ ],
188
+ "count": 6
189
+ }</code></pre>
190
+ </div>
191
+ </div>
192
+ </div>
193
+
194
+ <!-- Formats Endpoint -->
195
+ <div class="card endpoint-card" id="formats">
196
+ <div class="card-body">
197
+ <h4 class="card-title">
198
+ <span class="method-badge method-get">GET</span>
199
+ /api/formats
200
+ </h4>
201
+ <p class="card-text">Get available audio formats for speech generation.</p>
202
+
203
+ <h6>Available Formats</h6>
204
+ <p>We support multiple format requests, but internally:</p>
205
+ <ul>
206
+ <li><strong>mp3</strong> - Returns actual MP3 format</li>
207
+ <li><strong>All other formats</strong> (opus, aac, flac, wav, pcm) - Mapped to WAV format</li>
208
+ </ul>
209
+
210
+ <div class="alert alert-info">
211
+ <i class="fas fa-info-circle me-2"></i>
212
+ <strong>Note:</strong> When you request opus, aac, flac, wav, or pcm, you'll receive WAV audio data.
213
+ </div>
214
+
215
+ <h6>{{ _('docs.response_example') }}</h6>
216
+ <div class="response-example">
217
+ <pre><code>{
218
+ "formats": [
219
+ {
220
+ "id": "mp3",
221
+ "name": "MP3",
222
+ "mime_type": "audio/mp3",
223
+ "description": "MP3 audio format"
224
+ },
225
+ {
226
+ "id": "opus",
227
+ "name": "Opus",
228
+ "mime_type": "audio/wav",
229
+ "description": "Returns WAV format"
230
+ },
231
+ {
232
+ "id": "aac",
233
+ "name": "AAC",
234
+ "mime_type": "audio/wav",
235
+ "description": "Returns WAV format"
236
+ },
237
+ {
238
+ "id": "flac",
239
+ "name": "FLAC",
240
+ "mime_type": "audio/wav",
241
+ "description": "Returns WAV format"
242
+ },
243
+ {
244
+ "id": "wav",
245
+ "name": "WAV",
246
+ "mime_type": "audio/wav",
247
+ "description": "WAV audio format"
248
+ },
249
+ {
250
+ "id": "pcm",
251
+ "name": "PCM",
252
+ "mime_type": "audio/wav",
253
+ "description": "Returns WAV format"
254
+ }
255
+ ],
256
+ "count": 6
257
+ }</code></pre>
258
+ </div>
259
+ </div>
260
+ </div>
261
+
262
+ <!-- Text Validation Endpoint -->
263
+ <div class="card endpoint-card">
264
+ <div class="card-body">
265
+ <h4 class="card-title">
266
+ <span class="method-badge method-post">POST</span>
267
+ /api/validate-text
268
+ </h4>
269
+ <p class="card-text">{{ _('docs.validate_text_desc') }}</p>
270
+
271
+ <h6>{{ _('docs.request_body') }}</h6>
272
+ <div class="code-block">
273
+ <pre><code>{
274
+ "text": "Your text to validate",
275
+ "max_length": 4096
276
+ }</code></pre>
277
+ </div>
278
+
279
+ <h6>{{ _('docs.response_example') }}</h6>
280
+ <div class="response-example">
281
+ <pre><code>{
282
+ "text_length": 5000,
283
+ "max_length": 4096,
284
+ "is_valid": false,
285
+ "needs_splitting": true,
286
+ "suggested_chunks": 2,
287
+ "chunk_preview": [
288
+ "First chunk preview...",
289
+ "Second chunk preview..."
290
+ ]
291
+ }</code></pre>
292
+ </div>
293
+ </div>
294
+ </div>
295
+
296
+ <!-- Generate Speech Endpoint -->
297
+ <div class="card endpoint-card" id="generate">
298
+ <div class="card-body">
299
+ <h4 class="card-title">
300
+ <span class="method-badge method-post">POST</span>
301
+ /api/generate
302
+ </h4>
303
+ <p class="card-text">{{ _('docs.generate_speech_desc') }}</p>
304
+
305
+ <h6>{{ _('docs.request_body') }}</h6>
306
+ <div class="code-block">
307
+ <pre><code>{
308
+ "text": "Hello, world!",
309
+ "voice": "alloy",
310
+ "format": "mp3",
311
+ "instructions": "Speak cheerfully",
312
+ "max_length": 4096,
313
+ "validate_length": true
314
+ }</code></pre>
315
+ </div>
316
+
317
+ <h6>{{ _('docs.parameters') }}</h6>
318
+ <ul>
319
+ <li><code>text</code> ({{ _('docs.required') }}): {{ _('docs.text_param') }}</li>
320
+ <li><code>voice</code> ({{ _('docs.optional') }}): {{ _('docs.voice_param') }}</li>
321
+ <li><code>format</code> ({{ _('docs.optional') }}): {{ _('docs.format_param') }}</li>
322
+ <li><code>instructions</code> ({{ _('docs.optional') }}): {{ _('docs.instructions_param') }}</li>
323
+ <li><code>max_length</code> ({{ _('docs.optional') }}): {{ _('docs.max_length_param') }}</li>
324
+ <li><code>validate_length</code> ({{ _('docs.optional') }}): {{ _('docs.validate_length_param') }}</li>
325
+ </ul>
326
+
327
+ <h6>{{ _('docs.response') }}</h6>
328
+ <p>{{ _('docs.response_audio') }}</p>
329
+ </div>
330
+ </div>
331
+
332
+ </section>
333
+
334
+ <!-- Python Package -->
335
+ <section id="python-package" class="mb-5">
336
+ <h3 class="fw-bold mb-4">
337
+ <i class="fab fa-python me-2 text-warning"></i>{{ _('docs.python_package_title') }}
338
+ </h3>
339
+
340
+ <div class="card">
341
+ <div class="card-body">
342
+ <h5>{{ _('docs.long_text_support') }}</h5>
343
+ <p>{{ _('docs.long_text_desc') }}</p>
344
+
345
+ <div class="code-block">
346
+ <pre><code>from ttsfm import TTSClient, Voice, AudioFormat
347
+
348
+ # Create client
349
+ client = TTSClient()
350
+
351
+ # Generate speech from long text (automatically splits into separate files)
352
+ responses = client.generate_speech_long_text(
353
+ text="Very long text that exceeds 4096 characters...",
354
+ voice=Voice.ALLOY,
355
+ response_format=AudioFormat.MP3,
356
+ max_length=2000,
357
+ preserve_words=True
358
+ )
359
+
360
+ # Save each chunk as separate files
361
+ for i, response in enumerate(responses, 1):
362
+ response.save_to_file(f"part_{i:03d}.mp3")</code></pre>
363
+ </div>
364
+
365
+ <h6 class="mt-4">{{ _('docs.developer_features') }}</h6>
366
+ <ul>
367
+ <li><strong>{{ _('docs.manual_splitting') }}</strong></li>
368
+ <li><strong>{{ _('docs.word_preservation') }}</strong></li>
369
+ <li><strong>{{ _('docs.separate_files') }}</strong></li>
370
+ <li><strong>{{ _('docs.cli_support') }}</strong></li>
371
+ </ul>
372
+
373
+ <div class="alert alert-info">
374
+ <i class="fas fa-info-circle me-2"></i>
375
+ <strong>{{ _('docs.note') }}</strong> {{ _('docs.auto_combine_note') }}
376
+ </div>
377
+ </div>
378
+ </div>
379
+
380
+ <!-- Combined Audio Endpoints -->
381
+ <div class="card endpoint-card" id="combined">
382
+ <div class="card-body">
383
+ <h4 class="card-title">
384
+ <span class="method-badge method-post">POST</span>
385
+ /api/generate-combined
386
+ </h4>
387
+ <p class="card-text">{{ _('docs.combined_audio_desc') }}</p>
388
+
389
+ <h6>{{ _('docs.request_body') }}</h6>
390
+ <div class="code-block">
391
+ <pre><code>{
392
+ "text": "Very long text that exceeds the limit...",
393
+ "voice": "alloy",
394
+ "format": "mp3",
395
+ "instructions": "Optional voice instructions",
396
+ "max_length": 4096,
397
+ "preserve_words": true
398
+ }</code></pre>
399
+ </div>
400
+
401
+ <h6>{{ _('docs.response') }}</h6>
402
+ <p>{{ _('docs.response_combined_audio') }}</p>
403
+
404
+ <h6>{{ _('docs.response_headers') }}</h6>
405
+ <ul>
406
+ <li><code>X-Chunks-Combined</code>: {{ _('docs.chunks_combined_header') }}</li>
407
+ <li><code>X-Original-Text-Length</code>: {{ _('docs.original_text_length_header') }}</li>
408
+ <li><code>X-Audio-Size</code>: {{ _('docs.audio_size_header') }}</li>
409
+ </ul>
410
+ </div>
411
+ </div>
412
+
413
+ <!-- OpenAI Compatible Endpoint with Auto-Combine -->
414
+ <div class="card endpoint-card">
415
+ <div class="card-body">
416
+ <h4 class="card-title">
417
+ <span class="method-badge method-post">POST</span>
418
+ /v1/audio/speech
419
+ </h4>
420
+ <p class="card-text">{{ _('docs.openai_compatible_desc') }}</p>
421
+
422
+ <h6>{{ _('docs.request_body') }}</h6>
423
+ <div class="code-block">
424
+ <pre><code>{
425
+ "model": "gpt-4o-mini-tts",
426
+ "input": "Text of any length...",
427
+ "voice": "alloy",
428
+ "response_format": "mp3",
429
+ "instructions": "Optional voice instructions",
430
+ "speed": 1.0,
431
+ "auto_combine": true,
432
+ "max_length": 4096
433
+ }</code></pre>
434
+ </div>
435
+
436
+ <h6>{{ _('docs.enhanced_parameters') }}</h6>
437
+ <ul>
438
+ <li><strong>auto_combine</strong> (boolean, default: true):
439
+ <ul>
440
+ <li><code>true</code>: {{ _('docs.auto_combine_param') }}</li>
441
+ <li><code>false</code>: {{ _('docs.auto_combine_false') }}</li>
442
+ </ul>
443
+ </li>
444
+ <li><strong>max_length</strong> (integer, default: 4096): {{ _('docs.max_length_chunk_param') }}</li>
445
+ </ul>
446
+
447
+ <h6>{{ _('docs.response_headers') }}</h6>
448
+ <ul>
449
+ <li><code>X-Auto-Combine</code>: {{ _('docs.auto_combine_header') }}</li>
450
+ <li><code>X-Chunks-Combined</code>: {{ _('docs.chunks_combined_response') }}</li>
451
+ <li><code>X-Original-Text-Length</code>: {{ _('docs.original_text_response') }}</li>
452
+ <li><code>X-Audio-Format</code>: {{ _('docs.audio_format_header') }}</li>
453
+ <li><code>X-Audio-Size</code>: {{ _('docs.audio_size_response') }}</li>
454
+ </ul>
455
+
456
+ <h6>{{ _('docs.examples_title') }}</h6>
457
+ <div class="code-block">
458
+ <pre><code># {{ _('docs.short_text_comment') }}
459
+ curl -X POST {{ request.url_root }}v1/audio/speech \
460
+ -H "Content-Type: application/json" \
461
+ -d '{
462
+ "model": "gpt-4o-mini-tts",
463
+ "input": "Hello world!",
464
+ "voice": "alloy"
465
+ }'
466
+
467
+ # {{ _('docs.long_text_auto_comment') }}
468
+ curl -X POST {{ request.url_root }}v1/audio/speech \
469
+ -H "Content-Type: application/json" \
470
+ -d '{
471
+ "model": "gpt-4o-mini-tts",
472
+ "input": "Very long text...",
473
+ "voice": "alloy",
474
+ "auto_combine": true
475
+ }'
476
+
477
+ # {{ _('docs.long_text_no_auto_comment') }}
478
+ curl -X POST {{ request.url_root }}v1/audio/speech \
479
+ -H "Content-Type: application/json" \
480
+ -d '{
481
+ "model": "gpt-4o-mini-tts",
482
+ "input": "Very long text...",
483
+ "voice": "alloy",
484
+ "auto_combine": false
485
+ }'</code></pre>
486
+ </div>
487
+
488
+ <div class="alert alert-info mt-3">
489
+ <i class="fas fa-info-circle me-2"></i>
490
+ <strong>{{ _('docs.audio_combination') }}</strong> {{ _('docs.audio_combination_desc') }}
491
+ </div>
492
+
493
+ <h6 class="mt-4">{{ _('docs.use_cases') }}</h6>
494
+ <ul>
495
+ <li><strong>{{ _('docs.use_case_articles') }}</strong></li>
496
+ <li><strong>{{ _('docs.use_case_audiobooks') }}</strong></li>
497
+ <li><strong>{{ _('docs.use_case_podcasts') }}</strong></li>
498
+ <li><strong>{{ _('docs.use_case_education') }}</strong></li>
499
+ </ul>
500
+
501
+ <h6 class="mt-4">{{ _('docs.example_usage') }}</h6>
502
+ <div class="code-block">
503
+ <pre><code># {{ _('docs.python_example_comment') }}
504
+ import requests
505
+
506
+ response = requests.post(
507
+ "{{ request.url_root }}api/generate-combined",
508
+ json={
509
+ "text": "Your very long text content here...",
510
+ "voice": "nova",
511
+ "format": "mp3",
512
+ "max_length": 2000
513
+ }
514
+ )
515
+
516
+ if response.status_code == 200:
517
+ with open("combined_audio.mp3", "wb") as f:
518
+ f.write(response.content)
519
+
520
+ chunks = response.headers.get('X-Chunks-Combined')
521
+ print(f"Combined {chunks} chunks into single file")</code></pre>
522
+ </div>
523
+ </div>
524
+ </div>
525
+ </section>
526
+
527
+ <!-- WebSocket Streaming -->
528
+ <section id="websocket" class="mb-5">
529
+ <h2 class="mb-4">
530
+ <i class="fas fa-bolt text-warning me-2"></i>WebSocket Streaming
531
+ </h2>
532
+ <p class="lead">
533
+ Real-time audio streaming for enhanced user experience. Get audio chunks as they're generated instead of waiting for the complete file.
534
+ </p>
535
+
536
+ <div class="alert alert-info">
537
+ <i class="fas fa-info-circle me-2"></i>
538
+ WebSocket streaming provides lower perceived latency and real-time progress tracking for TTS generation.
539
+ </div>
540
+
541
+ <h3 class="mt-4">Connection</h3>
542
+ <div class="code-block">
543
+ <pre><code>// JavaScript WebSocket client
544
+ const client = new WebSocketTTSClient({
545
+ socketUrl: '{{ request.url_root[:-1] }}',
546
+ debug: true
547
+ });
548
+
549
+ // Connection events
550
+ client.onConnect = () => console.log('Connected');
551
+ client.onDisconnect = () => console.log('Disconnected');</code></pre>
552
+ </div>
553
+
554
+ <h3 class="mt-4">Streaming TTS Generation</h3>
555
+ <div class="code-block">
556
+ <pre><code>// Generate speech with real-time streaming
557
+ const result = await client.generateSpeech('Hello, WebSocket world!', {
558
+ voice: 'alloy',
559
+ format: 'mp3',
560
+ chunkSize: 1024, // Characters per chunk
561
+
562
+ // Progress callback
563
+ onProgress: (progress) => {
564
+ console.log(`Progress: ${progress.progress}%`);
565
+ console.log(`Chunks: ${progress.chunksCompleted}/${progress.totalChunks}`);
566
+ },
567
+
568
+ // Receive audio chunks in real-time
569
+ onChunk: (chunk) => {
570
+ console.log(`Received chunk ${chunk.chunkIndex + 1}`);
571
+ // Process or play audio chunk immediately
572
+ processAudioChunk(chunk.audioData);
573
+ },
574
+
575
+ // Completion callback
576
+ onComplete: (result) => {
577
+ console.log('Streaming complete!');
578
+ // result.audioData contains the complete audio
579
+ }
580
+ });</code></pre>
581
+ </div>
582
+
583
+ <h3 class="mt-4">WebSocket Events</h3>
584
+ <div class="endpoint-card card">
585
+ <div class="card-body">
586
+ <h5>Client → Server Events</h5>
587
+ <table class="table table-sm">
588
+ <thead>
589
+ <tr>
590
+ <th>Event</th>
591
+ <th>Description</th>
592
+ <th>Payload</th>
593
+ </tr>
594
+ </thead>
595
+ <tbody>
596
+ <tr>
597
+ <td><code>generate_stream</code></td>
598
+ <td>Start TTS generation</td>
599
+ <td><code>{text, voice, format, chunk_size}</code></td>
600
+ </tr>
601
+ <tr>
602
+ <td><code>cancel_stream</code></td>
603
+ <td>Cancel active stream</td>
604
+ <td><code>{request_id}</code></td>
605
+ </tr>
606
+ </tbody>
607
+ </table>
608
+
609
+ <h5 class="mt-4">Server → Client Events</h5>
610
+ <table class="table table-sm">
611
+ <thead>
612
+ <tr>
613
+ <th>Event</th>
614
+ <th>Description</th>
615
+ <th>Payload</th>
616
+ </tr>
617
+ </thead>
618
+ <tbody>
619
+ <tr>
620
+ <td><code>stream_started</code></td>
621
+ <td>Stream initiated</td>
622
+ <td><code>{request_id, timestamp}</code></td>
623
+ </tr>
624
+ <tr>
625
+ <td><code>audio_chunk</code></td>
626
+ <td>Audio chunk ready</td>
627
+ <td><code>{request_id, chunk_index, audio_data, duration}</code></td>
628
+ </tr>
629
+ <tr>
630
+ <td><code>stream_progress</code></td>
631
+ <td>Progress update</td>
632
+ <td><code>{progress, chunks_completed, total_chunks}</code></td>
633
+ </tr>
634
+ <tr>
635
+ <td><code>stream_complete</code></td>
636
+ <td>Generation complete</td>
637
+ <td><code>{request_id, total_chunks, status}</code></td>
638
+ </tr>
639
+ <tr>
640
+ <td><code>stream_error</code></td>
641
+ <td>Error occurred</td>
642
+ <td><code>{request_id, error, timestamp}</code></td>
643
+ </tr>
644
+ </tbody>
645
+ </table>
646
+ </div>
647
+ </div>
648
+
649
+ <h3 class="mt-4">Benefits</h3>
650
+ <ul>
651
+ <li><strong>Real-time feedback:</strong> Users see progress as audio generates</li>
652
+ <li><strong>Lower latency:</strong> First audio chunk arrives quickly</li>
653
+ <li><strong>Cancellable:</strong> Stop generation mid-stream if needed</li>
654
+ <li><strong>Efficient:</strong> Process chunks as they arrive</li>
655
+ </ul>
656
+
657
+ <h3 class="mt-4">Example: Streaming Audio Player</h3>
658
+ <div class="code-block">
659
+ <pre><code>// Create a streaming audio player
660
+ const audioChunks = [];
661
+ let isPlaying = false;
662
+
663
+ const streamingPlayer = await client.generateSpeech(longText, {
664
+ voice: 'nova',
665
+ format: 'mp3',
666
+
667
+ onChunk: (chunk) => {
668
+ // Store chunk
669
+ audioChunks.push(chunk.audioData);
670
+
671
+ // Start playing after first chunk
672
+ if (!isPlaying && audioChunks.length >= 3) {
673
+ startStreamingPlayback(audioChunks);
674
+ isPlaying = true;
675
+ }
676
+ },
677
+
678
+ onComplete: (result) => {
679
+ // Ensure all chunks are played
680
+ finishPlayback(result.audioData);
681
+ }
682
+ });</code></pre>
683
+ </div>
684
+
685
+ <div class="alert alert-success mt-4">
686
+ <h6><i class="fas fa-rocket me-2"></i>Try It Out!</h6>
687
+ <p class="mb-0">
688
+ Experience WebSocket streaming in action at the
689
+ <a href="/websocket-demo" class="alert-link">WebSocket Demo</a> or enable streaming mode in the
690
+ <a href="/playground" class="alert-link">Playground</a>.
691
+ </p>
692
+ </div>
693
+ </section>
694
+ </div>
695
+ </div>
696
+ </div>
697
+ {% endblock %}
698
+
699
+ {% block extra_js %}
700
+ <script>
701
+ // Smooth scrolling for TOC links
702
+ document.querySelectorAll('.toc a').forEach(link => {
703
+ link.addEventListener('click', function(e) {
704
+ e.preventDefault();
705
+ const target = document.querySelector(this.getAttribute('href'));
706
+ if (target) {
707
+ target.scrollIntoView({ behavior: 'smooth' });
708
+
709
+ // Update active link
710
+ document.querySelectorAll('.toc a').forEach(l => l.classList.remove('active'));
711
+ this.classList.add('active');
712
+ }
713
+ });
714
+ });
715
+
716
+ // Highlight current section in TOC
717
+ window.addEventListener('scroll', function() {
718
+ const sections = document.querySelectorAll('section[id]');
719
+ const scrollPos = window.scrollY + 100;
720
+
721
+ sections.forEach(section => {
722
+ const top = section.offsetTop;
723
+ const bottom = top + section.offsetHeight;
724
+ const id = section.getAttribute('id');
725
+ const link = document.querySelector(`.toc a[href="#${id}"]`);
726
+
727
+ if (scrollPos >= top && scrollPos < bottom) {
728
+ document.querySelectorAll('.toc a').forEach(l => l.classList.remove('active'));
729
+ if (link) link.classList.add('active');
730
+ }
731
+ });
732
+ });
733
+ </script>
734
+ {% endblock %}
templates/index.html ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}TTSFM - {{ _('home.title') }}{% endblock %}
4
+
5
+ {% block content %}
6
+ <!-- Hero Section -->
7
+ <section class="hero-section">
8
+ <div class="container">
9
+ <div class="row align-items-center min-vh-75">
10
+ <div class="col-lg-8 mx-auto text-center">
11
+ <div class="hero-content">
12
+ <div class="badge bg-primary text-white mb-3 px-3 py-2">
13
+ <i class="fas fa-code me-2"></i>Python Package
14
+ </div>
15
+ <h1 class="display-4 fw-bold mb-4">
16
+ {{ _('home.title') }}
17
+ </h1>
18
+ <p class="lead mb-4">
19
+ {{ _('home.subtitle') }}
20
+ </p>
21
+ <div class="d-flex flex-wrap gap-3 justify-content-center">
22
+ <a href="{{ url_for('playground') }}" class="btn btn-primary btn-lg">
23
+ <i class="fas fa-play me-2"></i>{{ _('home.try_demo') }}
24
+ </a>
25
+ <a href="{{ url_for('docs') }}" class="btn btn-outline-secondary btn-lg">
26
+ <i class="fas fa-book me-2"></i>{{ _('home.documentation') }}
27
+ </a>
28
+ <a href="https://github.com/dbccccccc/ttsfm" class="btn btn-outline-secondary btn-lg" target="_blank" rel="noopener noreferrer">
29
+ <i class="fab fa-github me-2"></i>{{ _('home.github') }}
30
+ </a>
31
+ </div>
32
+ </div>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ </section>
37
+
38
+ <!-- Features Section -->
39
+ <section class="py-5" style="background-color: #f8fafc;">
40
+ <div class="container">
41
+ <div class="row">
42
+ <div class="col-12 text-center mb-5">
43
+ <h2 class="fw-bold mb-4">{{ _('home.features_title') }}</h2>
44
+ <p class="lead text-muted">
45
+ {{ _('home.features_subtitle') }}
46
+ </p>
47
+ </div>
48
+ </div>
49
+
50
+ <div class="row g-4">
51
+ <div class="col-lg-3">
52
+ <div class="text-center">
53
+ <div class="feature-icon text-white rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 4rem; height: 4rem; background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);">
54
+ <i class="fas fa-key"></i>
55
+ </div>
56
+ <h5 class="fw-bold">{{ _('home.feature_free_title') }}</h5>
57
+ <p class="text-muted">{{ _('home.feature_free_desc') }}</p>
58
+ </div>
59
+ </div>
60
+
61
+ <div class="col-lg-3">
62
+ <div class="text-center">
63
+ <div class="feature-icon text-white rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 4rem; height: 4rem; background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%);">
64
+ <i class="fas fa-magic"></i>
65
+ </div>
66
+ <h5 class="fw-bold">{{ _('home.feature_openai_title') }} <span class="badge bg-success ms-1">v3.2.3</span></h5>
67
+ <p class="text-muted">{{ _('home.feature_openai_desc') }}</p>
68
+ </div>
69
+ </div>
70
+
71
+ <div class="col-lg-3">
72
+ <div class="text-center">
73
+ <div class="feature-icon text-white rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 4rem; height: 4rem; background: linear-gradient(135deg, #059669 0%, #10b981 100%);">
74
+ <i class="fas fa-bolt"></i>
75
+ </div>
76
+ <h5 class="fw-bold">{{ _('home.feature_async_title') }}</h5>
77
+ <p class="text-muted">{{ _('home.feature_async_desc') }}</p>
78
+ </div>
79
+ </div>
80
+
81
+ <div class="col-lg-3">
82
+ <div class="text-center">
83
+ <div class="feature-icon text-white rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 4rem; height: 4rem; background: linear-gradient(135deg, #6b7280 0%, #9ca3af 100%);">
84
+ <i class="fas fa-microphone-alt"></i>
85
+ </div>
86
+ <h5 class="fw-bold">{{ _('home.feature_voices_title') }} & {{ _('home.feature_formats_title') }}</h5>
87
+ <p class="text-muted">{{ _('home.feature_voices_desc') }} {{ _('home.feature_formats_desc') }}</p>
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ </section>
93
+
94
+ <!-- Quick Start Section -->
95
+ <section class="py-5">
96
+ <div class="container">
97
+ <div class="row">
98
+ <div class="col-12 text-center mb-5">
99
+ <h2 class="fw-bold mb-4">{{ _('home.quick_start_title') }}</h2>
100
+ <p class="lead text-muted">
101
+ {{ _('home.subtitle') }}
102
+ </p>
103
+ </div>
104
+ </div>
105
+
106
+ <div class="row g-4">
107
+ <div class="col-lg-6">
108
+ <div class="card h-100">
109
+ <div class="card-body">
110
+ <h5 class="card-title">
111
+ <i class="fas fa-download me-2 text-primary"></i>{{ _('home.installation_title') }}
112
+ </h5>
113
+ <pre class="bg-light p-3 rounded"><code>{{ _('home.installation_code') }}</code></pre>
114
+ <small class="text-muted">Requires Python 3.8+</small>
115
+ </div>
116
+ </div>
117
+ </div>
118
+
119
+ <div class="col-lg-6">
120
+ <div class="card h-100">
121
+ <div class="card-body">
122
+ <h5 class="card-title">
123
+ <i class="fas fa-play me-2 text-success"></i>{{ _('home.usage_title') }}
124
+ </h5>
125
+ <pre class="bg-light p-3 rounded"><code>from ttsfm import TTSClient, Voice, AudioFormat
126
+
127
+ client = TTSClient()
128
+ response = client.generate_speech(
129
+ text="Hello, world!",
130
+ voice=Voice.ALLOY,
131
+ response_format=AudioFormat.MP3
132
+ )
133
+ response.save_to_file("hello")</code></pre>
134
+ <small class="text-muted">No API keys required</small>
135
+ </div>
136
+ </div>
137
+ </div>
138
+ </div>
139
+
140
+ <div class="row mt-4">
141
+ <div class="col-12 text-center">
142
+ <div class="d-flex justify-content-center gap-3 flex-wrap">
143
+ <a href="{{ url_for('playground') }}" class="btn btn-primary">
144
+ <i class="fas fa-play me-2"></i>{{ _('home.try_demo') }}
145
+ </a>
146
+ <a href="{{ url_for('docs') }}" class="btn btn-outline-primary">
147
+ <i class="fas fa-book me-2"></i>{{ _('home.documentation') }}
148
+ </a>
149
+ </div>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ </section>
154
+
155
+
156
+ {% endblock %}
templates/playground.html ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}TTSFM {{ _('nav.playground') }} - {{ _('playground.title') }}{% endblock %}
4
+
5
+ {% block content %}
6
+ <!-- Clean Playground Header -->
7
+ <section class="py-5" style="background-color: white; border-bottom: 1px solid #e5e7eb;">
8
+ <div class="container">
9
+ <div class="row align-items-center">
10
+ <div class="col-lg-8">
11
+ <div class="fade-in">
12
+ <div class="badge bg-primary text-white mb-3 px-3 py-2">
13
+ <i class="fas fa-flask me-2"></i>Demo
14
+ </div>
15
+ <h1 class="display-4 fw-bold mb-3 text-dark">
16
+ <i class="fas fa-play-circle me-3 text-primary"></i>{{ _('playground.title') }}
17
+ </h1>
18
+ <p class="lead mb-4 text-muted">
19
+ {{ _('playground.subtitle') }}
20
+ </p>
21
+ </div>
22
+ </div>
23
+ <div class="col-lg-4 text-center">
24
+ <div class="playground-visual fade-in" style="animation-delay: 0.3s;">
25
+ <div class="playground-icon">
26
+ <i class="fas fa-waveform-lines text-primary"></i>
27
+ <div class="pulse-ring"></div>
28
+ <div class="pulse-ring pulse-ring-delay"></div>
29
+ </div>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </div>
34
+ </section>
35
+
36
+ <div class="container py-5 playground">
37
+
38
+ <div class="row">
39
+ <div class="col-lg-10 mx-auto">
40
+ <div class="card shadow-lg-custom border-0 fade-in">
41
+ <div class="card-header bg-gradient-primary text-white">
42
+ <h4 class="mb-0 d-flex align-items-center">
43
+ <i class="fas fa-microphone me-2"></i>
44
+ {{ _('playground.title') }}
45
+ </h4>
46
+ </div>
47
+ <div class="card-body p-4">
48
+ <form id="tts-form" onsubmit="return false;">
49
+ <!-- Enhanced Text Input -->
50
+ <div class="mb-4">
51
+ <label for="text-input" class="form-label fw-bold d-flex align-items-center">
52
+ <i class="fas fa-edit me-2 text-primary"></i>
53
+ {{ _('playground.text_input_label') }}
54
+ </label>
55
+ <div class="position-relative">
56
+ <textarea
57
+ class="form-control shadow-sm"
58
+ id="text-input"
59
+ rows="4"
60
+ placeholder="{{ _('playground.text_input_placeholder') }}"
61
+ required
62
+ >Hello! This is a test of the TTSFM text-to-speech system.</textarea>
63
+ <div class="position-absolute top-0 end-0 p-2">
64
+ <button type="button" class="btn btn-sm btn-outline-secondary" id="clear-text-btn" title="Clear text">
65
+ <i class="fas fa-times"></i>
66
+ </button>
67
+ </div>
68
+ </div>
69
+ <div class="form-text d-flex justify-content-between align-items-center">
70
+ <div class="d-flex align-items-center gap-3">
71
+ <span class="text-muted">
72
+ <i class="fas fa-keyboard me-1"></i>
73
+ <span id="char-count">0</span> {{ _('playground.character_count') }}
74
+ </span>
75
+ <span id="length-status" class=""></span>
76
+ <span id="auto-combine-status" class="badge bg-success d-none">
77
+ <i class="fas fa-magic me-1"></i>{{ _('playground.max_length_warning') }}
78
+ </span>
79
+ <span class="text-muted small">
80
+ <i class="fas fa-lightbulb me-1"></i>
81
+ Tip: Use Ctrl+Enter to generate
82
+ </span>
83
+ </div>
84
+ <div class="btn-group" role="group">
85
+ <button type="button" class="btn btn-sm btn-outline-primary" id="validate-text-btn">
86
+ <i class="fas fa-check me-1"></i>{{ _('common.validate') if _('common.validate') != 'common.validate' else 'Validate' }}
87
+ </button>
88
+ <button type="button" class="btn btn-sm btn-outline-secondary" id="random-text-btn">
89
+ <i class="fas fa-dice me-1"></i>{{ _('playground.random_text') }}
90
+ </button>
91
+ </div>
92
+ </div>
93
+ <div id="validation-result" class="mt-2 d-none"></div>
94
+ </div>
95
+
96
+ <div class="row">
97
+ <!-- Enhanced Voice Selection -->
98
+ <div class="col-md-6 mb-4">
99
+ <label for="voice-select" class="form-label fw-bold d-flex align-items-center">
100
+ <i class="fas fa-microphone me-2 text-primary"></i>
101
+ {{ _('playground.voice_label') }}
102
+ </label>
103
+ <select class="form-select shadow-sm" id="voice-select" required>
104
+ <option value="">{{ _('common.loading_voices') }}</option>
105
+ </select>
106
+ <div class="form-text">
107
+ <span>{{ _('common.choose_voice') }}</span>
108
+ </div>
109
+ </div>
110
+
111
+ <!-- Enhanced Format Selection -->
112
+ <div class="col-md-6 mb-4">
113
+ <label for="format-select" class="form-label fw-bold d-flex align-items-center">
114
+ <i class="fas fa-file-audio me-2 text-primary"></i>
115
+ {{ _('playground.format_label') }}
116
+ </label>
117
+ <select class="form-select shadow-sm" id="format-select" required>
118
+ <option value="">{{ _('common.loading_formats') }}</option>
119
+ </select>
120
+ <div class="form-text">
121
+ <span>{{ _('common.select_format') }}</span>
122
+ </div>
123
+ </div>
124
+ </div>
125
+
126
+ <!-- Advanced Options -->
127
+ <div class="row">
128
+ <div class="col-md-6 mb-4">
129
+ <label for="max-length-input" class="form-label fw-bold">
130
+ <i class="fas fa-ruler me-2"></i>{{ _('common.max_length') }}
131
+ </label>
132
+ <input
133
+ type="number"
134
+ class="form-control"
135
+ id="max-length-input"
136
+ value="4096"
137
+ min="100"
138
+ max="10000"
139
+ >
140
+ <div class="form-text">
141
+ {{ _('playground.max_length_description') }}
142
+ </div>
143
+ </div>
144
+
145
+ <div class="col-md-6 mb-4">
146
+ <label class="form-label fw-bold">
147
+ <i class="fas fa-cog me-2"></i>{{ _('common.options') }}
148
+ </label>
149
+ <div class="form-check">
150
+ <input class="form-check-input" type="checkbox" id="validate-length-check" checked>
151
+ <label class="form-check-label" for="validate-length-check">
152
+ {{ _('playground.enable_length_validation') }}
153
+ </label>
154
+ </div>
155
+ <div class="form-check">
156
+ <input class="form-check-input" type="checkbox" id="auto-combine-check" checked>
157
+ <label class="form-check-label" for="auto-combine-check">
158
+ <span class="fw-bold text-primary">{{ _('playground.auto_combine_long_text') }}</span>
159
+ <i class="fas fa-info-circle ms-1" data-bs-toggle="tooltip"
160
+ title="{{ _('playground.auto_combine_tooltip') }}"></i>
161
+ </label>
162
+ <div class="form-text small">
163
+ <i class="fas fa-magic me-1"></i>
164
+ {{ _('playground.auto_combine_description') }}
165
+ </div>
166
+ </div>
167
+ </div>
168
+ </div>
169
+
170
+ <!-- Instructions (Optional) -->
171
+ <div class="mb-4">
172
+ <label for="instructions-input" class="form-label fw-bold">
173
+ <i class="fas fa-magic me-2"></i>{{ _('playground.instructions_label') }}
174
+ </label>
175
+ <input
176
+ type="text"
177
+ class="form-control"
178
+ id="instructions-input"
179
+ placeholder="{{ _('playground.instructions_placeholder') }}"
180
+ >
181
+ <div class="form-text">
182
+ {{ _('playground.instructions_description') }}
183
+ </div>
184
+ </div>
185
+
186
+ <!-- API Key (Optional) -->
187
+ <div class="mb-4" id="api-key-section">
188
+ <label for="api-key-input" class="form-label fw-bold">
189
+ <i class="fas fa-key me-2"></i>{{ _('playground.api_key_optional') }}
190
+ </label>
191
+ <div class="input-group">
192
+ <input
193
+ type="password"
194
+ class="form-control"
195
+ id="api-key-input"
196
+ placeholder="{{ _('playground.api_key_placeholder') }}"
197
+ >
198
+ <button class="btn btn-outline-secondary" type="button" id="toggle-api-key-visibility">
199
+ <i class="fas fa-eye" id="api-key-eye-icon"></i>
200
+ </button>
201
+ </div>
202
+ <div class="form-text">
203
+ <i class="fas fa-info-circle me-1"></i>
204
+ {{ _('playground.api_key_description') }}
205
+ </div>
206
+ </div>
207
+
208
+ <!-- Enhanced Generate Button -->
209
+ <div class="text-center mb-4">
210
+ <div class="d-grid gap-2 d-md-block">
211
+ <button type="submit" class="btn btn-primary btn-lg px-4 py-3" id="generate-btn">
212
+ <span class="btn-text">
213
+ <i class="fas fa-magic me-2"></i>{{ _('playground.generate_speech') }}
214
+ </span>
215
+ <span class="loading-spinner">
216
+ <i class="fas fa-spinner fa-spin me-2"></i>{{ _('playground.generating') }}
217
+ </span>
218
+ </button>
219
+ <button type="button" class="btn btn-outline-secondary btn-lg ms-md-3" id="reset-form-btn">
220
+ <i class="fas fa-redo me-2"></i>{{ _('common.reset') }}
221
+ </button>
222
+ </div>
223
+ </div>
224
+ </form>
225
+
226
+ <!-- Enhanced Audio Player -->
227
+ <div id="audio-result" class="d-none">
228
+ <div class="border-top pt-4 mt-4">
229
+ <div class="d-flex align-items-center justify-content-between mb-3">
230
+ <h5 class="mb-0 d-flex align-items-center">
231
+ <i class="fas fa-volume-up me-2 text-success"></i>
232
+ {{ _('playground.audio_player_title') }}
233
+ <span class="badge bg-success ms-2">
234
+ <i class="fas fa-check me-1"></i>Ready
235
+ </span>
236
+ </h5>
237
+ <div class="btn-group" role="group">
238
+ <button type="button" class="btn btn-sm btn-outline-primary" id="replay-btn" title="Replay audio">
239
+ <i class="fas fa-redo"></i>
240
+ </button>
241
+ <button type="button" class="btn btn-sm btn-outline-secondary" id="share-btn" title="Share audio">
242
+ <i class="fas fa-share"></i>
243
+ </button>
244
+ </div>
245
+ </div>
246
+
247
+ <div class="audio-player-container bg-light rounded p-3 mb-3">
248
+ <audio controls class="audio-player w-100" id="audio-player" preload="metadata">
249
+ Your browser does not support the audio element.
250
+ </audio>
251
+ <div class="audio-controls mt-2 d-flex justify-content-between align-items-center">
252
+ <div class="audio-info">
253
+ <span id="audio-info" class="text-muted small"></span>
254
+ </div>
255
+ <div class="audio-actions">
256
+ <button type="button" class="btn btn-success btn-sm" id="download-btn">
257
+ <i class="fas fa-download me-1"></i>{{ _('playground.download_audio') }}
258
+ </button>
259
+ </div>
260
+ </div>
261
+ </div>
262
+
263
+ <div class="audio-stats row text-center">
264
+ <div class="col-md-3 col-6">
265
+ <div class="stat-item">
266
+ <i class="fas fa-clock text-primary"></i>
267
+ <div class="stat-value" id="audio-duration">--</div>
268
+ <div class="stat-label">{{ _('playground.duration') }}</div>
269
+ </div>
270
+ </div>
271
+ <div class="col-md-3 col-6">
272
+ <div class="stat-item">
273
+ <i class="fas fa-file text-info"></i>
274
+ <div class="stat-value" id="audio-size">--</div>
275
+ <div class="stat-label">{{ _('playground.file_size') }}</div>
276
+ </div>
277
+ </div>
278
+ <div class="col-md-3 col-6">
279
+ <div class="stat-item">
280
+ <i class="fas fa-microphone text-warning"></i>
281
+ <div class="stat-value" id="audio-voice">--</div>
282
+ <div class="stat-label">{{ _('playground.voice') }}</div>
283
+ </div>
284
+ </div>
285
+ <div class="col-md-3 col-6">
286
+ <div class="stat-item">
287
+ <i class="fas fa-music text-success"></i>
288
+ <div class="stat-value" id="audio-format">--</div>
289
+ <div class="stat-label">{{ _('playground.format') }}</div>
290
+ </div>
291
+ </div>
292
+ </div>
293
+ </div>
294
+ </div>
295
+
296
+
297
+ </div>
298
+ </div>
299
+ </div>
300
+ </div>
301
+ </div>
302
+ {% endblock %}
303
+
304
+ {% block extra_js %}
305
+ <!-- Socket.IO for WebSocket support -->
306
+ <script src="https://cdn.socket.io/4.6.0/socket.io.min.js"></script>
307
+ <!-- WebSocket TTS Client -->
308
+ <script src="{{ url_for('static', filename='js/websocket-tts.js') }}"></script>
309
+ <!-- Enhanced Playground JavaScript with WebSocket Support -->
310
+ <script src="{{ url_for('static', filename='js/playground-enhanced-fixed.js') }}"></script>
311
+ <script>
312
+ // Additional playground-specific functionality
313
+ console.log('TTSFM Enhanced Playground with WebSocket support loaded successfully!');
314
+
315
+
316
+ </script>
317
+ {% endblock %}
templates/websocket_demo.html ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}{{ _('websocket.title', 'WebSocket Streaming Demo') }} - TTSFM{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="container mt-5">
7
+ <div class="row">
8
+ <div class="col-lg-10 mx-auto">
9
+ <h1 class="text-center mb-4">
10
+ <i class="fas fa-bolt text-warning"></i>
11
+ {{ _('websocket.title', 'WebSocket Streaming Demo') }}
12
+ </h1>
13
+
14
+ <!-- Connection Status -->
15
+ <div class="alert alert-info" id="connection-status">
16
+ <i class="fas fa-plug me-2"></i>
17
+ <span id="status-text">Connecting to WebSocket server...</span>
18
+ </div>
19
+
20
+ <!-- Input Form -->
21
+ <div class="card shadow-sm mb-4">
22
+ <div class="card-body">
23
+ <h5 class="card-title">{{ _('playground.generate_speech', 'Generate Speech') }}</h5>
24
+
25
+ <form id="streaming-form">
26
+ <div class="mb-3">
27
+ <label for="text-input" class="form-label">
28
+ {{ _('playground.text_input', 'Text to Convert') }}
29
+ </label>
30
+ <textarea
31
+ class="form-control"
32
+ id="text-input"
33
+ rows="4"
34
+ maxlength="4096"
35
+ placeholder="{{ _('playground.text_placeholder', 'Enter your text here...') }}"
36
+ >Experience the future of text-to-speech with real-time WebSocket streaming! This innovative feature delivers audio chunks as they're generated, providing a more responsive and engaging user experience.</textarea>
37
+ <div class="form-text">
38
+ <i class="fas fa-info-circle me-1"></i>
39
+ Streaming will split text into chunks for real-time delivery
40
+ </div>
41
+ </div>
42
+
43
+ <div class="row">
44
+ <div class="col-md-6 mb-3">
45
+ <label for="voice-select" class="form-label">
46
+ {{ _('playground.voice', 'Voice') }}
47
+ </label>
48
+ <select class="form-select" id="voice-select">
49
+ <option value="alloy">Alloy</option>
50
+ <option value="echo">Echo</option>
51
+ <option value="fable">Fable</option>
52
+ <option value="onyx">Onyx</option>
53
+ <option value="nova">Nova</option>
54
+ <option value="shimmer">Shimmer</option>
55
+ </select>
56
+ </div>
57
+
58
+ <div class="col-md-6 mb-3">
59
+ <label for="format-select" class="form-label">
60
+ {{ _('playground.format', 'Audio Format') }}
61
+ </label>
62
+ <select class="form-select" id="format-select">
63
+ <option value="mp3">MP3</option>
64
+ <option value="wav">WAV</option>
65
+ <option value="opus">OPUS</option>
66
+ </select>
67
+ </div>
68
+ </div>
69
+
70
+ <div class="d-grid gap-2 d-md-flex justify-content-md-end">
71
+ <button type="submit" class="btn btn-primary" id="stream-btn">
72
+ <i class="fas fa-bolt me-2"></i>
73
+ Start Streaming
74
+ </button>
75
+ <button type="button" class="btn btn-danger" id="cancel-btn" style="display: none;">
76
+ <i class="fas fa-stop me-2"></i>
77
+ Cancel
78
+ </button>
79
+ </div>
80
+ </form>
81
+ </div>
82
+ </div>
83
+
84
+ <!-- Progress Section -->
85
+ <div class="card shadow-sm mb-4" id="progress-section" style="display: none;">
86
+ <div class="card-body">
87
+ <h5 class="card-title">Streaming Progress</h5>
88
+
89
+ <div class="progress mb-3" style="height: 25px;">
90
+ <div
91
+ class="progress-bar progress-bar-striped progress-bar-animated"
92
+ id="progress-bar"
93
+ role="progressbar"
94
+ style="width: 0%"
95
+ >
96
+ <span id="progress-text">0%</span>
97
+ </div>
98
+ </div>
99
+
100
+ <div class="row text-center">
101
+ <div class="col-md-4">
102
+ <h6>Chunks Received</h6>
103
+ <p class="h4"><span id="chunks-received">0</span> / <span id="total-chunks">0</span></p>
104
+ </div>
105
+ <div class="col-md-4">
106
+ <h6>Data Transferred</h6>
107
+ <p class="h4" id="data-transferred">0 KB</p>
108
+ </div>
109
+ <div class="col-md-4">
110
+ <h6>Generation Time</h6>
111
+ <p class="h4" id="generation-time">0.0s</p>
112
+ </div>
113
+ </div>
114
+ </div>
115
+ </div>
116
+
117
+ <!-- Audio Chunks Display -->
118
+ <div class="card shadow-sm mb-4" id="chunks-section" style="display: none;">
119
+ <div class="card-body">
120
+ <h5 class="card-title">Audio Chunks</h5>
121
+ <div id="chunks-container" class="row g-2">
122
+ <!-- Chunks will be added here dynamically -->
123
+ </div>
124
+ </div>
125
+ </div>
126
+
127
+ <!-- Final Audio Player -->
128
+ <div class="card shadow-sm" id="audio-section" style="display: none;">
129
+ <div class="card-body">
130
+ <h5 class="card-title">Generated Audio</h5>
131
+ <audio id="audio-player" controls class="w-100"></audio>
132
+ <div class="mt-2">
133
+ <button class="btn btn-success" id="download-btn">
134
+ <i class="fas fa-download me-2"></i>
135
+ Download Audio
136
+ </button>
137
+ </div>
138
+ </div>
139
+ </div>
140
+
141
+ <!-- Info Section -->
142
+ <div class="card shadow-sm mt-4">
143
+ <div class="card-body">
144
+ <h5 class="card-title">
145
+ <i class="fas fa-info-circle text-info me-2"></i>
146
+ About WebSocket Streaming
147
+ </h5>
148
+ <p>
149
+ This demo showcases real-time audio streaming using WebSockets. Instead of waiting
150
+ for the entire audio to be generated, you receive chunks as they're processed,
151
+ providing immediate feedback and a more responsive experience.
152
+ </p>
153
+ <ul>
154
+ <li><strong>Lower Perceived Latency:</strong> Start receiving audio before generation completes</li>
155
+ <li><strong>Progress Tracking:</strong> Real-time updates on generation progress</li>
156
+ <li><strong>Cancellable:</strong> Stop generation mid-stream if needed</li>
157
+ <li><strong>Efficient:</strong> Stream chunks as they're ready, no waiting</li>
158
+ </ul>
159
+ </div>
160
+ </div>
161
+ </div>
162
+ </div>
163
+ </div>
164
+
165
+ <!-- Include Socket.IO -->
166
+ <script src="https://cdn.socket.io/4.6.0/socket.io.min.js"></script>
167
+ <!-- Include our WebSocket client -->
168
+ <script src="{{ url_for('static', filename='js/websocket-tts.js') }}"></script>
169
+
170
+ <script>
171
+ // Initialize WebSocket client
172
+ let wsClient = null;
173
+ let currentRequestId = null;
174
+ let startTime = null;
175
+
176
+ // Initialize on page load
177
+ document.addEventListener('DOMContentLoaded', function() {
178
+ // Create WebSocket client
179
+ wsClient = new WebSocketTTSClient({
180
+ debug: true,
181
+ onConnect: () => {
182
+ updateConnectionStatus('connected');
183
+ },
184
+ onDisconnect: () => {
185
+ updateConnectionStatus('disconnected');
186
+ },
187
+ onError: (error) => {
188
+ updateConnectionStatus('error');
189
+ showError(`Connection error: ${error.message}`);
190
+ }
191
+ });
192
+
193
+ // Form submission
194
+ document.getElementById('streaming-form').addEventListener('submit', handleStreamingSubmit);
195
+
196
+ // Cancel button
197
+ document.getElementById('cancel-btn').addEventListener('click', handleCancel);
198
+ });
199
+
200
+ function updateConnectionStatus(status) {
201
+ const statusEl = document.getElementById('connection-status');
202
+ const statusText = document.getElementById('status-text');
203
+
204
+ statusEl.className = 'alert';
205
+
206
+ switch(status) {
207
+ case 'connected':
208
+ statusEl.classList.add('alert-success');
209
+ statusText.innerHTML = '<i class="fas fa-check-circle me-2"></i>Connected to WebSocket server';
210
+ break;
211
+ case 'disconnected':
212
+ statusEl.classList.add('alert-warning');
213
+ statusText.innerHTML = '<i class="fas fa-exclamation-triangle me-2"></i>Disconnected from server';
214
+ break;
215
+ case 'error':
216
+ statusEl.classList.add('alert-danger');
217
+ statusText.innerHTML = '<i class="fas fa-times-circle me-2"></i>Connection error';
218
+ break;
219
+ default:
220
+ statusEl.classList.add('alert-info');
221
+ statusText.innerHTML = '<i class="fas fa-plug me-2"></i>Connecting...';
222
+ }
223
+ }
224
+
225
+ async function handleStreamingSubmit(e) {
226
+ e.preventDefault();
227
+
228
+ if (!wsClient || !wsClient.isConnected()) {
229
+ showError('WebSocket not connected. Please refresh the page.');
230
+ return;
231
+ }
232
+
233
+ // Get form values
234
+ const text = document.getElementById('text-input').value.trim();
235
+ const voice = document.getElementById('voice-select').value;
236
+ const format = document.getElementById('format-select').value;
237
+
238
+ if (!text) {
239
+ showError('Please enter some text to convert.');
240
+ return;
241
+ }
242
+
243
+ // Reset UI
244
+ resetUI();
245
+
246
+ // Show progress section
247
+ document.getElementById('progress-section').style.display = 'block';
248
+ document.getElementById('chunks-section').style.display = 'block';
249
+ document.getElementById('stream-btn').disabled = true;
250
+ document.getElementById('cancel-btn').style.display = 'inline-block';
251
+
252
+ startTime = Date.now();
253
+
254
+ try {
255
+ const result = await wsClient.generateSpeech(text, {
256
+ voice: voice,
257
+ format: format,
258
+ chunkSize: 512, // Smaller chunks for more updates
259
+ onStart: (data) => {
260
+ currentRequestId = data.request_id;
261
+ console.log('Stream started:', data);
262
+ },
263
+ onProgress: (progress) => {
264
+ updateProgress(progress);
265
+ },
266
+ onChunk: (chunk) => {
267
+ handleAudioChunk(chunk);
268
+ },
269
+ onComplete: (result) => {
270
+ handleStreamComplete(result);
271
+ },
272
+ onError: (error) => {
273
+ showError(`Streaming error: ${error.message}`);
274
+ }
275
+ });
276
+
277
+ console.log('Streaming completed:', result);
278
+
279
+ } catch (error) {
280
+ showError(`Failed to generate speech: ${error.message}`);
281
+ resetUI();
282
+ }
283
+ }
284
+
285
+ function updateProgress(progress) {
286
+ const progressBar = document.getElementById('progress-bar');
287
+ const progressText = document.getElementById('progress-text');
288
+ const chunksReceived = document.getElementById('chunks-received');
289
+ const totalChunks = document.getElementById('total-chunks');
290
+ const generationTime = document.getElementById('generation-time');
291
+
292
+ progressBar.style.width = `${progress.progress}%`;
293
+ progressText.textContent = `${progress.progress}%`;
294
+ chunksReceived.textContent = progress.chunksCompleted;
295
+ totalChunks.textContent = progress.totalChunks;
296
+
297
+ if (startTime) {
298
+ const elapsed = (Date.now() - startTime) / 1000;
299
+ generationTime.textContent = `${elapsed.toFixed(1)}s`;
300
+ }
301
+ }
302
+
303
+ function handleAudioChunk(chunk) {
304
+ const container = document.getElementById('chunks-container');
305
+
306
+ // Create chunk visualization
307
+ const chunkEl = document.createElement('div');
308
+ chunkEl.className = 'col-auto';
309
+ chunkEl.innerHTML = `
310
+ <div class="badge bg-primary p-2" title="Chunk ${chunk.chunkIndex + 1}">
311
+ <i class="fas fa-music me-1"></i>
312
+ ${chunk.chunkIndex + 1}
313
+ <small class="d-block">${(chunk.audioData.byteLength / 1024).toFixed(1)}KB</small>
314
+ </div>
315
+ `;
316
+
317
+ container.appendChild(chunkEl);
318
+
319
+ // Update data transferred
320
+ const currentData = parseFloat(document.getElementById('data-transferred').textContent);
321
+ const newData = currentData + (chunk.audioData.byteLength / 1024);
322
+ document.getElementById('data-transferred').textContent = `${newData.toFixed(1)} KB`;
323
+ }
324
+
325
+ function handleStreamComplete(result) {
326
+ // Create blob from combined audio
327
+ const blob = new Blob([result.audioData], { type: `audio/${result.format}` });
328
+ const url = URL.createObjectURL(blob);
329
+
330
+ // Set up audio player
331
+ const audioPlayer = document.getElementById('audio-player');
332
+ audioPlayer.src = url;
333
+
334
+ // Show audio section
335
+ document.getElementById('audio-section').style.display = 'block';
336
+
337
+ // Set up download button
338
+ document.getElementById('download-btn').onclick = () => {
339
+ const a = document.createElement('a');
340
+ a.href = url;
341
+ a.download = `tts_stream_${Date.now()}.${result.format}`;
342
+ a.click();
343
+ };
344
+
345
+ // Update final stats
346
+ document.getElementById('generation-time').textContent = `${(result.generationTime / 1000).toFixed(2)}s`;
347
+
348
+ // Reset buttons
349
+ document.getElementById('stream-btn').disabled = false;
350
+ document.getElementById('cancel-btn').style.display = 'none';
351
+
352
+ // Update progress bar to success
353
+ const progressBar = document.getElementById('progress-bar');
354
+ progressBar.classList.remove('progress-bar-animated');
355
+ progressBar.classList.add('bg-success');
356
+ }
357
+
358
+ function handleCancel() {
359
+ if (currentRequestId) {
360
+ wsClient.cancelStream(currentRequestId);
361
+ showInfo('Stream cancelled');
362
+ resetUI();
363
+ }
364
+ }
365
+
366
+ function resetUI() {
367
+ document.getElementById('progress-section').style.display = 'none';
368
+ document.getElementById('chunks-section').style.display = 'none';
369
+ document.getElementById('audio-section').style.display = 'none';
370
+ document.getElementById('stream-btn').disabled = false;
371
+ document.getElementById('cancel-btn').style.display = 'none';
372
+ document.getElementById('chunks-container').innerHTML = '';
373
+ document.getElementById('progress-bar').style.width = '0%';
374
+ document.getElementById('progress-bar').className = 'progress-bar progress-bar-striped progress-bar-animated';
375
+ document.getElementById('data-transferred').textContent = '0 KB';
376
+ currentRequestId = null;
377
+ startTime = null;
378
+ }
379
+
380
+ function showError(message) {
381
+ console.error(message);
382
+ // You could add a toast notification here
383
+ }
384
+
385
+ function showInfo(message) {
386
+ console.info(message);
387
+ // You could add a toast notification here
388
+ }
389
+ </script>
390
+ {% endblock %}
translations/en.json ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "nav": {
3
+ "home": "Home",
4
+ "playground": "Playground",
5
+ "documentation": "Documentation",
6
+ "github": "GitHub",
7
+ "status_checking": "Checking...",
8
+ "status_online": "Online",
9
+ "status_offline": "Offline"
10
+ },
11
+ "common": {
12
+ "loading": "Loading...",
13
+ "error": "Error",
14
+ "success": "Success",
15
+ "warning": "Warning",
16
+ "info": "Info",
17
+ "close": "Close",
18
+ "save": "Save",
19
+ "cancel": "Cancel",
20
+ "confirm": "Confirm",
21
+ "download": "Download",
22
+ "upload": "Upload",
23
+ "generate": "Generate",
24
+ "play": "Play",
25
+ "stop": "Stop",
26
+ "pause": "Pause",
27
+ "resume": "Resume",
28
+ "clear": "Clear",
29
+ "reset": "Reset",
30
+ "copy": "Copy",
31
+ "copied": "Copied!",
32
+ "language": "Language",
33
+ "english": "English",
34
+ "chinese": "中文",
35
+ "validate": "Validate",
36
+ "options": "Options",
37
+ "max_length": "Max Length",
38
+ "tip": "Tip",
39
+ "choose_voice": "Choose from available voices",
40
+ "select_format": "Select your preferred audio format",
41
+ "loading_voices": "Loading voices...",
42
+ "loading_formats": "Loading formats...",
43
+ "ctrl_enter_tip": "Use Ctrl+Enter to generate",
44
+ "auto_combine_enabled": "Auto-combine enabled"
45
+ },
46
+ "home": {
47
+ "title": "Free Text-to-Speech for Python",
48
+ "subtitle": "Generate high-quality speech from text using the free openai.fm service. No API keys, no registration - just install and start creating audio.",
49
+ "try_demo": "Try Demo",
50
+ "documentation": "Documentation",
51
+ "github": "GitHub",
52
+ "features_title": "Key Features",
53
+ "features_subtitle": "Simple, free, and powerful text-to-speech for Python developers.",
54
+ "feature_free_title": "Completely Free",
55
+ "feature_free_desc": "No API keys or registration required. Uses the free openai.fm service.",
56
+ "feature_voices_title": "11 Voices",
57
+ "feature_voices_desc": "All OpenAI-compatible voices available for different use cases.",
58
+ "feature_formats_title": "6 Audio Formats",
59
+ "feature_formats_desc": "MP3, WAV, OPUS, AAC, FLAC, and PCM support for any application.",
60
+ "feature_docker_title": "Docker Ready",
61
+ "feature_docker_desc": "One-command deployment with web interface and API endpoints.",
62
+ "feature_openai_title": "OpenAI Compatible",
63
+ "feature_openai_desc": "Drop-in replacement for OpenAI's TTS API with auto-combine for long text.",
64
+ "feature_async_title": "Async & Sync",
65
+ "feature_async_desc": "Both asyncio and synchronous clients for maximum flexibility.",
66
+ "quick_start_title": "Quick Start",
67
+ "installation_title": "Installation",
68
+ "installation_code": "pip install ttsfm",
69
+ "usage_title": "Basic Usage",
70
+ "docker_title": "Docker Deployment",
71
+ "docker_desc": "Run TTSFM with web interface:",
72
+ "api_title": "OpenAI-Compatible API",
73
+ "api_desc": "Use with OpenAI Python client:",
74
+ "footer_copyright": "© 2024 dbcccc"
75
+ },
76
+ "playground": {
77
+ "title": "Interactive TTS Playground",
78
+ "subtitle": "Test different voices and audio formats in real-time",
79
+ "text_input_label": "Text to Convert",
80
+ "text_input_placeholder": "Enter the text you want to convert to speech...",
81
+ "voice_label": "Voice",
82
+ "format_label": "Audio Format",
83
+ "instructions_label": "Voice Instructions (Optional)",
84
+ "instructions_placeholder": "Additional instructions for voice generation...",
85
+ "character_count": "characters",
86
+ "max_length_warning": "Text exceeds maximum length. It will be automatically split and combined.",
87
+ "generate_speech": "Generate Speech",
88
+ "generating": "Generating...",
89
+ "download_audio": "Download Audio",
90
+ "audio_player_title": "Generated Audio",
91
+ "file_size": "File Size",
92
+ "duration": "Duration",
93
+ "format": "Format",
94
+ "voice": "Voice",
95
+ "chunks_combined": "Chunks Combined",
96
+ "random_text": "Random Text",
97
+ "clear_text": "Clear Text",
98
+ "max_length_description": "Maximum characters per request (default: 4096)",
99
+ "enable_length_validation": "Enable length validation",
100
+ "auto_combine_long_text": "Auto-combine long text",
101
+ "auto_combine_tooltip": "Automatically split long text and combine audio chunks into a single file",
102
+ "auto_combine_description": "Automatically handles text longer than the limit",
103
+ "instructions_description": "Provide optional instructions for voice modulation",
104
+ "api_key_optional": "API Key (Optional)",
105
+ "api_key_placeholder": "Enter your API key if required",
106
+ "api_key_description": "Only required if API key protection is enabled on the server",
107
+ "sample_texts": {
108
+ "welcome": "Welcome to TTSFM! This is a free text-to-speech service that converts your text into high-quality audio using advanced AI technology.",
109
+ "story": "Once upon a time, in a digital world far away, there lived a small Python package that could transform any text into beautiful speech. This package was called TTSFM, and it brought joy to developers everywhere.",
110
+ "technical": "TTSFM is a Python client for text-to-speech APIs that provides both synchronous and asynchronous interfaces. It supports multiple voices and audio formats, making it perfect for various applications.",
111
+ "multilingual": "TTSFM supports multiple languages and voices, allowing you to create diverse audio content for global audiences. The service is completely free and requires no API keys.",
112
+ "long": "This is a longer text sample designed to test the auto-combine feature of TTSFM. When text exceeds the maximum length limit, TTSFM automatically splits it into smaller chunks, generates audio for each chunk, and then seamlessly combines them into a single audio file. This process is completely transparent to the user and ensures that you can convert text of any length without worrying about technical limitations. The resulting audio maintains consistent quality and natural flow throughout the entire content."
113
+ },
114
+ "error_messages": {
115
+ "empty_text": "Please enter some text to convert.",
116
+ "generation_failed": "Failed to generate speech. Please try again.",
117
+ "network_error": "Network error. Please check your connection and try again.",
118
+ "invalid_format": "Invalid audio format selected.",
119
+ "invalid_voice": "Invalid voice selected.",
120
+ "text_too_long": "Text is too long. Please reduce the length or enable auto-combine.",
121
+ "server_error": "Server error. Please try again later."
122
+ },
123
+ "success_messages": {
124
+ "generation_complete": "Speech generated successfully!",
125
+ "text_copied": "Text copied to clipboard!",
126
+ "download_started": "Download started!"
127
+ }
128
+ },
129
+ "docs": {
130
+ "title": "API Documentation",
131
+ "subtitle": "Complete reference for the TTSFM Text-to-Speech API. Free, simple, and powerful.",
132
+ "contents": "Contents",
133
+ "overview": "Overview",
134
+ "authentication": "Authentication",
135
+ "text_validation": "Text Validation",
136
+ "endpoints": "API Endpoints",
137
+ "voices": "Voices",
138
+ "formats": "Audio Formats",
139
+ "generate": "Generate Speech",
140
+ "combined": "Combined Audio",
141
+ "status": "Status & Health",
142
+ "errors": "Error Handling",
143
+ "examples": "Code Examples",
144
+ "python_package": "Python Package",
145
+ "overview_title": "Overview",
146
+ "overview_desc": "The TTSFM API provides a modern, OpenAI-compatible interface for text-to-speech generation. It supports multiple voices, audio formats, and includes advanced features like text length validation and intelligent auto-combine functionality.",
147
+ "base_url": "Base URL:",
148
+ "key_features": "Key Features",
149
+ "feature_voices": "11 different voice options - Choose from alloy, echo, nova, and more",
150
+ "feature_formats": "Multiple audio formats - MP3, WAV, OPUS, AAC, FLAC, PCM support",
151
+ "feature_openai": "OpenAI compatibility - Drop-in replacement for OpenAI's TTS API",
152
+ "feature_auto_combine": "Auto-combine feature - Automatically handles long text (>4096 chars) by splitting and combining audio",
153
+ "feature_validation": "Text length validation - Smart validation with configurable limits",
154
+ "feature_monitoring": "Real-time monitoring - Status endpoints and health checks",
155
+ "new_version": "New in v3.2.3:",
156
+ "new_version_desc": "Enhanced `/v1/audio/speech` endpoint with intelligent auto-combine feature. Streamlined web interface with clean, user-friendly design and automatic long-text handling!",
157
+ "authentication_title": "Authentication",
158
+ "authentication_desc": "Currently, the API supports optional API key authentication. If configured, include your API key in the request headers.",
159
+ "text_validation_title": "Text Length Validation",
160
+ "text_validation_desc": "TTSFM includes built-in text length validation to ensure compatibility with TTS models. The default maximum length is 4096 characters, but this can be customized.",
161
+ "important": "Important:",
162
+ "text_validation_warning": "Text exceeding the maximum length will be rejected unless validation is disabled or the text is split into chunks.",
163
+ "validation_options": "Validation Options",
164
+ "max_length_option": "Maximum allowed characters (default: 4096)",
165
+ "validate_length_option": "Enable/disable validation (default: true)",
166
+ "preserve_words_option": "Avoid splitting words when chunking (default: true)",
167
+ "endpoints_title": "API Endpoints",
168
+ "get_voices_desc": "Get list of available voices.",
169
+ "get_formats_desc": "Get list of supported audio formats.",
170
+ "validate_text_desc": "Validate text length and get splitting suggestions.",
171
+ "generate_speech_desc": "Generate speech from text.",
172
+ "response_example": "Response Example:",
173
+ "request_body": "Request Body:",
174
+ "parameters": "Parameters:",
175
+ "text_param": "Text to convert to speech",
176
+ "voice_param": "Voice ID (default: \"alloy\")",
177
+ "format_param": "Audio format (default: \"mp3\")",
178
+ "instructions_param": "Voice modulation instructions",
179
+ "max_length_param": "Maximum text length (default: 4096)",
180
+ "validate_length_param": "Enable validation (default: true)",
181
+ "response": "Response:",
182
+ "response_audio": "Returns audio file with appropriate Content-Type header.",
183
+ "response_combined_audio": "Returns a single audio file containing all chunks combined seamlessly.",
184
+ "required": "required",
185
+ "optional": "optional",
186
+ "python_package_title": "Python Package",
187
+ "long_text_support": "Long Text Support",
188
+ "long_text_desc": "The TTSFM Python package includes built-in long text splitting functionality for developers who need fine-grained control:",
189
+ "developer_features": "Developer Features:",
190
+ "manual_splitting": "Manual Splitting: Full control over text chunking for advanced use cases",
191
+ "word_preservation": "Word Preservation: Maintains word boundaries for natural speech",
192
+ "separate_files": "Separate Files: Each chunk saved as individual audio file",
193
+ "cli_support": "CLI Support: Use `--split-long-text` flag for command-line usage",
194
+ "note": "Note:",
195
+ "auto_combine_note": "For web users, the auto-combine feature in `/v1/audio/speech` is recommended as it automatically handles long text and returns a single seamless audio file.",
196
+ "combined_audio_desc": "Generate a single combined audio file from long text. Automatically splits text into chunks, generates speech for each chunk, and combines them into one seamless audio file.",
197
+ "response_headers": "Response Headers:",
198
+ "chunks_combined_header": "Number of chunks that were combined",
199
+ "original_text_length_header": "Original text length in characters",
200
+ "audio_size_header": "Final audio file size in bytes",
201
+ "openai_compatible_desc": "Enhanced OpenAI-compatible endpoint with auto-combine feature. Automatically handles long text by splitting and combining audio chunks when needed.",
202
+ "enhanced_parameters": "Enhanced Parameters:",
203
+ "auto_combine_param": "Automatically split long text and combine audio chunks into a single file",
204
+ "auto_combine_false": "Return error if text exceeds max_length (standard OpenAI behavior)",
205
+ "max_length_chunk_param": "Maximum characters per chunk when splitting",
206
+ "auto_combine_header": "Whether auto-combine was enabled (true/false)",
207
+ "chunks_combined_response": "Number of audio chunks combined (1 for short text)",
208
+ "original_text_response": "Original text length (for long text processing)",
209
+ "audio_format_header": "Audio format of the response",
210
+ "audio_size_response": "Audio file size in bytes",
211
+ "short_text_comment": "Short text (works normally)",
212
+ "long_text_auto_comment": "Long text with auto-combine (default)",
213
+ "long_text_no_auto_comment": "Long text without auto-combine (will error)",
214
+ "audio_combination": "Audio Combination:",
215
+ "audio_combination_desc": "Uses advanced audio processing (PyDub) when available, with intelligent fallbacks for different environments. Supports all audio formats.",
216
+ "use_cases": "Use Cases:",
217
+ "use_case_articles": "Long Articles: Convert blog posts or articles to single audio files",
218
+ "use_case_audiobooks": "Audiobooks: Generate chapters as single audio files",
219
+ "use_case_podcasts": "Podcasts: Create podcast episodes from scripts",
220
+ "use_case_education": "Educational Content: Convert learning materials to audio",
221
+ "example_usage": "Example Usage:",
222
+ "python_example_comment": "Python example"
223
+ }
224
+ }
translations/zh.json ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "nav": {
3
+ "home": "首页",
4
+ "playground": "试用平台",
5
+ "documentation": "文档",
6
+ "github": "GitHub",
7
+ "status_checking": "检查中...",
8
+ "status_online": "在线",
9
+ "status_offline": "离线"
10
+ },
11
+ "common": {
12
+ "loading": "加载中...",
13
+ "error": "错误",
14
+ "success": "成功",
15
+ "warning": "警告",
16
+ "info": "信息",
17
+ "close": "关闭",
18
+ "save": "保存",
19
+ "cancel": "取消",
20
+ "confirm": "确认",
21
+ "download": "下载",
22
+ "upload": "上传",
23
+ "generate": "生成",
24
+ "play": "播放",
25
+ "stop": "停止",
26
+ "pause": "暂停",
27
+ "resume": "继续",
28
+ "clear": "清除",
29
+ "reset": "重置",
30
+ "copy": "复制",
31
+ "copied": "已复制!",
32
+ "language": "语言",
33
+ "english": "English",
34
+ "chinese": "中文",
35
+ "validate": "验证",
36
+ "options": "选项",
37
+ "max_length": "最大长度",
38
+ "tip": "提示",
39
+ "choose_voice": "从可用声音中选择",
40
+ "select_format": "选择您偏好的音频格式",
41
+ "loading_voices": "加载声音中...",
42
+ "loading_formats": "加载格式中...",
43
+ "ctrl_enter_tip": "使用 Ctrl+Enter 生成",
44
+ "auto_combine_enabled": "自动合并已启用"
45
+ },
46
+ "home": {
47
+ "title": "免费的Python文本转语音",
48
+ "subtitle": "使用免费的openai.fm服务从文本生成高质量语音。无需API密钥,无需注册 - 只需安装即可开始创建音频。",
49
+ "try_demo": "试用演示",
50
+ "documentation": "文档",
51
+ "github": "GitHub",
52
+ "features_title": "主要特性",
53
+ "features_subtitle": "简单、免费且强大的Python开发者文本转语音工具。",
54
+ "feature_free_title": "完全免费",
55
+ "feature_free_desc": "无需API密钥或注册。使用免费的openai.fm服务。",
56
+ "feature_voices_title": "11种声音",
57
+ "feature_voices_desc": "提供所有OpenAI兼容的声音,适用于不同使用场景。",
58
+ "feature_formats_title": "6种音频格式",
59
+ "feature_formats_desc": "支持MP3、WAV、OPUS、AAC、FLAC和PCM格式,适用于任何应用。",
60
+ "feature_docker_title": "Docker就绪",
61
+ "feature_docker_desc": "一键部署,包含Web界面和API端点。",
62
+ "feature_openai_title": "OpenAI兼容",
63
+ "feature_openai_desc": "OpenAI TTS API的直接替代品,支持长文本自动合并。",
64
+ "feature_async_title": "异步和同步",
65
+ "feature_async_desc": "提供asyncio和同步客户端,最大化灵活性。",
66
+ "quick_start_title": "快速开始",
67
+ "installation_title": "安装",
68
+ "installation_code": "pip install ttsfm",
69
+ "usage_title": "基本用法",
70
+ "docker_title": "Docker部署",
71
+ "docker_desc": "运行带有Web界面的TTSFM:",
72
+ "api_title": "OpenAI兼容API",
73
+ "api_desc": "与OpenAI Python客户端一起使用:",
74
+ "footer_copyright": "© 2024 dbcccc"
75
+ },
76
+ "playground": {
77
+ "title": "交互式TTS试用平台",
78
+ "subtitle": "实时测试不同的声音和音频格式",
79
+ "text_input_label": "要转换的文本",
80
+ "text_input_placeholder": "输入您想要转换为语音的文本...",
81
+ "voice_label": "声音",
82
+ "format_label": "音频格式",
83
+ "instructions_label": "声音指令(可选)",
84
+ "instructions_placeholder": "语音生成的额外指令...",
85
+ "character_count": "字符",
86
+ "max_length_warning": "文本超过最大长度。将自动分割并合并。",
87
+ "generate_speech": "生成语音",
88
+ "generating": "生成中...",
89
+ "download_audio": "下载音频",
90
+ "audio_player_title": "生成的音频",
91
+ "file_size": "文件大小",
92
+ "duration": "时长",
93
+ "format": "格式",
94
+ "voice": "声音",
95
+ "chunks_combined": "合并片段",
96
+ "random_text": "随机文本",
97
+ "clear_text": "清除文本",
98
+ "max_length_description": "每个请求的最大字符数(默认:4096)",
99
+ "enable_length_validation": "启用长度验证",
100
+ "auto_combine_long_text": "自动合并长文本",
101
+ "auto_combine_tooltip": "自动分割长文本并将音频片段合并为单个文件",
102
+ "auto_combine_description": "自动处理超过限制的文本",
103
+ "instructions_description": "为声音调制提供可选指令",
104
+ "api_key_optional": "API密钥(可选)",
105
+ "api_key_placeholder": "如果需要,请输入您的API密钥",
106
+ "api_key_description": "仅在服务器启用API密钥保护时需要",
107
+ "sample_texts": {
108
+ "welcome": "欢迎使用TTSFM!这是一个免费的文本转语音服务,使用先进的AI技术将您的文本转换为高质量音频。",
109
+ "story": "很久很久以前,在一个遥远的数字世界里,住着一个小小的Python包,它能够将任何文本转换成美妙的语音。这个包叫做TTSFM,它为世界各地的开发者带来了快乐。",
110
+ "technical": "TTSFM是一个用于文本转语音API的Python客户端,提供同步和异步接口。它支持多种声音和音频格式,非常适合各种应用。",
111
+ "multilingual": "TTSFM支持多种语言和声音,让您能够为全球受众创建多样化的音频内容。该服务完全免费,无需API密钥。",
112
+ "long": "这是一个较长的文本示例,用于测试TTSFM的自动合并功能。当文本超过最大长度限制时,TTSFM会自动将其分割成较小的片段,为每个片段生成音频,然后无缝地将它们合并成一个音频文件。这个过程对用户完全透明,确保您可以转换任何长度的文本,而无需担心技术限制。生成的音频在整个内容中保持一致的质量和自然的流畅性。"
113
+ },
114
+ "error_messages": {
115
+ "empty_text": "请输入要转换的文本。",
116
+ "generation_failed": "语音生成失败。请重试。",
117
+ "network_error": "网络错误。请检查您的连接并重试。",
118
+ "invalid_format": "选择的音频格式无效。",
119
+ "invalid_voice": "选择的声音无效。",
120
+ "text_too_long": "文本太长。请减少长度或启用自动合并。",
121
+ "server_error": "服务器错误。请稍后重试。"
122
+ },
123
+ "success_messages": {
124
+ "generation_complete": "语音生成成功!",
125
+ "text_copied": "文本已复制到剪贴板!",
126
+ "download_started": "下载已开始!"
127
+ }
128
+ },
129
+ "docs": {
130
+ "title": "API文档",
131
+ "subtitle": "TTSFM文本转语音API的完整参考。免费、简单且强大。",
132
+ "contents": "目录",
133
+ "overview": "概述",
134
+ "authentication": "身份验证",
135
+ "text_validation": "文本验证",
136
+ "endpoints": "API端点",
137
+ "voices": "声音",
138
+ "formats": "音频格式",
139
+ "generate": "生成语音",
140
+ "combined": "合并音频",
141
+ "status": "状态和健康检查",
142
+ "errors": "错误处理",
143
+ "examples": "代码示例",
144
+ "python_package": "Python包",
145
+ "overview_title": "概述",
146
+ "overview_desc": "TTSFM API提供现代的、OpenAI兼容的文本转语音生成接口。它支持多种声音、音频格式,并包含高级功能,如文本长度验证和智能自动合并功能。",
147
+ "base_url": "基础URL:",
148
+ "key_features": "主要特性",
149
+ "feature_voices": "11种不同的声音选项 - 从alloy、echo、nova等中选择",
150
+ "feature_formats": "多种音频格式 - 支持MP3、WAV、OPUS、AAC、FLAC、PCM",
151
+ "feature_openai": "OpenAI兼容性 - OpenAI TTS API的直接替代品",
152
+ "feature_auto_combine": "自动合并功能 - 自动处理长文本(>4096字符),通过分割和合并音频",
153
+ "feature_validation": "文本长度验证 - 智能验证,可配置限制",
154
+ "feature_monitoring": "实时监控 - 状态端点和健康检查",
155
+ "new_version": "v3.2.3新功能:",
156
+ "new_version_desc": "增强的`/v1/audio/speech`端点,具有智能自动合并功能。简化的Web界面,设计简洁、用户友好,自动处理长文本!",
157
+ "authentication_title": "身份验证",
158
+ "authentication_desc": "目前,API支持可选的API密钥身份验证。如果已配置,请在请求头中包含您的API密钥。",
159
+ "text_validation_title": "文本长度验证",
160
+ "text_validation_desc": "TTSFM包含内置的文本长度验证,以确保与TTS模型的兼容性。默认最大长度为4096个字符,但可以自定义。",
161
+ "important": "重要:",
162
+ "text_validation_warning": "超过最大长度的文本将被拒绝,除非禁用验证或将文本分割成块。",
163
+ "validation_options": "验证选项",
164
+ "max_length_option": "允许的最大字符数(默认:4096)",
165
+ "validate_length_option": "启用/禁用验证(默认:true)",
166
+ "preserve_words_option": "分块时避免分割单词(默认:true)",
167
+ "endpoints_title": "API端点",
168
+ "get_voices_desc": "获取可用声音列表。",
169
+ "get_formats_desc": "获取支持的音频格式列表。",
170
+ "validate_text_desc": "验证文本长度并获取分割建议。",
171
+ "generate_speech_desc": "从文本生成语音。",
172
+ "response_example": "响应示例:",
173
+ "request_body": "请求体:",
174
+ "parameters": "参数:",
175
+ "text_param": "要转换为语音的文本",
176
+ "voice_param": "声音ID(默认:\"alloy\")",
177
+ "format_param": "音频格式(默认:\"mp3\")",
178
+ "instructions_param": "声音调制指令",
179
+ "max_length_param": "最大文本长度(默认:4096)",
180
+ "validate_length_param": "启用验证(默认:true)",
181
+ "response": "响应:",
182
+ "response_audio": "返回带有适当Content-Type头的音频文件。",
183
+ "response_combined_audio": "返回包含所有块无缝合并的单个音频文件。",
184
+ "required": "必需",
185
+ "optional": "可选",
186
+ "python_package_title": "Python包",
187
+ "long_text_support": "长文本支持",
188
+ "long_text_desc": "TTSFM Python包包含内置的长文本分割功能,为需要精细控制的开发者提供支持:",
189
+ "developer_features": "开发者功能:",
190
+ "manual_splitting": "手动分割:对高级用例的文本分块进行完全控制",
191
+ "word_preservation": "单词保护:维护单词边界以获得自然语音",
192
+ "separate_files": "单独文件:每个块保存为单独的音频文件",
193
+ "cli_support": "CLI支持:使用`--split-long-text`标志进行命令行使用",
194
+ "note": "注意:",
195
+ "auto_combine_note": "对于Web用户,建议使用`/v1/audio/speech`中的自动合并功能,因为它会自动处理长文本并返回单个无缝音频文件。",
196
+ "combined_audio_desc": "从长文本生成单个合并的音频文件。自动将文本分割成块,为每个块生成语音,并将它们合并成一个无缝的音频文件。",
197
+ "response_headers": "响应头:",
198
+ "chunks_combined_header": "合并的块数",
199
+ "original_text_length_header": "原始文本长度(字符数)",
200
+ "audio_size_header": "最终音频文件大小(字节)",
201
+ "openai_compatible_desc": "增强的OpenAI兼容端点,具有自动合并功能。在需要时自动处理长文本,通过分割和合并音频块。",
202
+ "enhanced_parameters": "增强参数:",
203
+ "auto_combine_param": "自动分割长文本并将音频块合并为单个文件",
204
+ "auto_combine_false": "如果文本超过max_length则返回错误(标准OpenAI行为)",
205
+ "max_length_chunk_param": "分割时每个块的最大字符数",
206
+ "auto_combine_header": "是否启用了自动合并(true/false)",
207
+ "chunks_combined_response": "合并的音频块数(短文本为1)",
208
+ "original_text_response": "原始文本长度(用于长文本处理)",
209
+ "audio_format_header": "响应的音频格式",
210
+ "audio_size_response": "音频文件大小(字节)",
211
+ "short_text_comment": "短文本(正常工作)",
212
+ "long_text_auto_comment": "带自动合并的长文本(默认)",
213
+ "long_text_no_auto_comment": "不带自动合并的长文本(将出错)",
214
+ "audio_combination": "音频合并:",
215
+ "audio_combination_desc": "在可用时使用高级音频处理(PyDub),在不同环境中具有智能回退。支持所有音频格式。",
216
+ "use_cases": "使用场景:",
217
+ "use_case_articles": "长文章:将博客文章或文章转换为单个音频文件",
218
+ "use_case_audiobooks": "有声书:将章节生成为单个音频文件",
219
+ "use_case_podcasts": "播客:从脚本创建播客剧集",
220
+ "use_case_education": "教育内容:将学习材料转换为音频",
221
+ "example_usage": "使用示例:",
222
+ "python_example_comment": "Python示例"
223
+ }
224
+ }
websocket_handler.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ WebSocket handler for real-time TTS streaming.
3
+
4
+ Because apparently waiting 2 seconds for audio generation is too much for modern users.
5
+ At least this will make it FEEL faster.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ import uuid
12
+ import time
13
+ from typing import Optional, Dict, Any
14
+ from datetime import datetime
15
+
16
+ from flask_socketio import SocketIO, emit, disconnect
17
+ from flask import request
18
+
19
+ from ttsfm import TTSClient, Voice, AudioFormat, TTSException
20
+ from ttsfm.utils import split_text_by_length, estimate_audio_duration
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class WebSocketTTSHandler:
26
+ """
27
+ Handles WebSocket connections for streaming TTS generation.
28
+
29
+ Because your users can't wait 2 seconds for a complete response.
30
+ """
31
+
32
+ def __init__(self, socketio: SocketIO, tts_client: TTSClient):
33
+ self.socketio = socketio
34
+ self.tts_client = tts_client
35
+ self.active_sessions: Dict[str, Dict[str, Any]] = {}
36
+
37
+ # Register WebSocket events
38
+ self._register_events()
39
+
40
+ def _register_events(self):
41
+ """Register all WebSocket event handlers."""
42
+
43
+ @self.socketio.on('connect')
44
+ def handle_connect():
45
+ """Handle new WebSocket connection."""
46
+ session_id = request.sid
47
+ self.active_sessions[session_id] = {
48
+ 'connected_at': datetime.now(),
49
+ 'request_count': 0,
50
+ 'last_request': None
51
+ }
52
+ logger.info(f"WebSocket client connected: {session_id}")
53
+ emit('connected', {'session_id': session_id, 'status': 'ready'})
54
+
55
+ @self.socketio.on('disconnect')
56
+ def handle_disconnect():
57
+ """Handle WebSocket disconnection."""
58
+ session_id = request.sid
59
+ if session_id in self.active_sessions:
60
+ del self.active_sessions[session_id]
61
+ logger.info(f"WebSocket client disconnected: {session_id}")
62
+
63
+ @self.socketio.on('generate_stream')
64
+ def handle_generate_stream(data):
65
+ """
66
+ Handle streaming TTS generation request.
67
+
68
+ Expected data format:
69
+ {
70
+ 'text': str,
71
+ 'voice': str,
72
+ 'format': str,
73
+ 'chunk_size': int (optional, default 1024 chars),
74
+ 'instructions': str (optional, voice modulation instructions)
75
+ }
76
+ """
77
+ session_id = request.sid
78
+ request_id = data.get('request_id', str(uuid.uuid4()))
79
+
80
+ # Update session info
81
+ if session_id in self.active_sessions:
82
+ self.active_sessions[session_id]['request_count'] += 1
83
+ self.active_sessions[session_id]['last_request'] = datetime.now()
84
+
85
+ # Emit acknowledgment
86
+ emit('stream_started', {
87
+ 'request_id': request_id,
88
+ 'timestamp': time.time()
89
+ })
90
+
91
+ # Start async generation
92
+ self.socketio.start_background_task(
93
+ self._generate_stream,
94
+ session_id,
95
+ request_id,
96
+ data
97
+ )
98
+
99
+ @self.socketio.on('cancel_stream')
100
+ def handle_cancel_stream(data):
101
+ """Handle stream cancellation request."""
102
+ request_id = data.get('request_id')
103
+ session_id = request.sid
104
+
105
+ # In a real implementation, you'd track and cancel the actual generation
106
+ logger.info(f"Stream cancellation requested: {request_id}")
107
+ emit('stream_cancelled', {'request_id': request_id})
108
+
109
+ def _generate_stream(self, session_id: str, request_id: str, data: Dict[str, Any]):
110
+ """
111
+ Generate TTS audio in chunks and stream to client.
112
+
113
+ This is where the magic happens. And by magic, I mean
114
+ chunking text and pretending it's real-time.
115
+ """
116
+ try:
117
+ # Extract parameters
118
+ text = data.get('text', '')
119
+ voice = data.get('voice', 'alloy')
120
+ format_str = data.get('format', 'mp3')
121
+ chunk_size = data.get('chunk_size', 1024)
122
+ instructions = data.get('instructions', None) # Voice instructions support!
123
+
124
+ if not text:
125
+ self._emit_error(session_id, request_id, "No text provided")
126
+ return
127
+
128
+ # Convert string parameters to enums
129
+ try:
130
+ voice_enum = Voice(voice.lower())
131
+ format_enum = AudioFormat(format_str.lower())
132
+ except ValueError as e:
133
+ self._emit_error(session_id, request_id, f"Invalid parameter: {str(e)}")
134
+ return
135
+
136
+ # Split text into chunks for "streaming" effect
137
+ chunks = split_text_by_length(text, chunk_size, preserve_words=True)
138
+ total_chunks = len(chunks)
139
+
140
+ logger.info(f"Starting stream generation: {request_id} with {total_chunks} chunks")
141
+
142
+ # Emit initial progress
143
+ self.socketio.emit('stream_progress', {
144
+ 'request_id': request_id,
145
+ 'progress': 0,
146
+ 'total_chunks': total_chunks,
147
+ 'status': 'processing'
148
+ }, room=session_id)
149
+
150
+ # Process each chunk
151
+ for i, chunk in enumerate(chunks):
152
+ # Check if client is still connected
153
+ if session_id not in self.active_sessions:
154
+ logger.warning(f"Client disconnected during generation: {session_id}")
155
+ break
156
+
157
+ try:
158
+ # Generate audio for chunk
159
+ start_time = time.time()
160
+ response = self.tts_client.generate_speech(
161
+ text=chunk,
162
+ voice=voice_enum,
163
+ response_format=format_enum,
164
+ instructions=instructions, # Pass voice instructions!
165
+ validate_length=False # We already chunked it
166
+ )
167
+ generation_time = time.time() - start_time
168
+
169
+ # Emit chunk data
170
+ chunk_data = {
171
+ 'request_id': request_id,
172
+ 'chunk_index': i,
173
+ 'total_chunks': total_chunks,
174
+ 'audio_data': response.audio_data.hex(), # Convert bytes to hex string
175
+ 'format': format_enum.value,
176
+ 'duration': response.duration,
177
+ 'generation_time': generation_time,
178
+ 'chunk_text': chunk[:50] + '...' if len(chunk) > 50 else chunk
179
+ }
180
+
181
+ self.socketio.emit('audio_chunk', chunk_data, room=session_id)
182
+
183
+ # Emit progress update
184
+ progress = int(((i + 1) / total_chunks) * 100)
185
+ self.socketio.emit('stream_progress', {
186
+ 'request_id': request_id,
187
+ 'progress': progress,
188
+ 'total_chunks': total_chunks,
189
+ 'chunks_completed': i + 1,
190
+ 'status': 'processing'
191
+ }, room=session_id)
192
+
193
+ # Small delay to prevent overwhelming the client
194
+ # (and to make it feel more "real-time")
195
+ self.socketio.sleep(0.1)
196
+
197
+ except Exception as e:
198
+ logger.error(f"Error generating chunk {i}: {str(e)}")
199
+ self._emit_error(session_id, request_id, f"Chunk {i} generation failed: {str(e)}")
200
+ # Continue with next chunk instead of failing completely
201
+ continue
202
+
203
+ # Emit completion
204
+ self.socketio.emit('stream_complete', {
205
+ 'request_id': request_id,
206
+ 'total_chunks': total_chunks,
207
+ 'status': 'completed',
208
+ 'timestamp': time.time()
209
+ }, room=session_id)
210
+
211
+ logger.info(f"Stream generation completed: {request_id}")
212
+
213
+ except Exception as e:
214
+ logger.error(f"Stream generation failed: {str(e)}")
215
+ self._emit_error(session_id, request_id, str(e))
216
+
217
+ def _emit_error(self, session_id: str, request_id: str, error_message: str):
218
+ """Emit error to specific session."""
219
+ self.socketio.emit('stream_error', {
220
+ 'request_id': request_id,
221
+ 'error': error_message,
222
+ 'timestamp': time.time()
223
+ }, room=session_id)
224
+
225
+ def get_active_sessions_count(self) -> int:
226
+ """Get count of active WebSocket sessions."""
227
+ return len(self.active_sessions)
228
+
229
+ def get_session_info(self, session_id: str) -> Optional[Dict[str, Any]]:
230
+ """Get information about a specific session."""
231
+ return self.active_sessions.get(session_id)