github-actions[bot] commited on
Commit
6203f3a
·
1 Parent(s): 7cce965

Auto-deploy from GitHub: 6cd5884f5f59c8acb069f7a2a8f8ce8558f018b5

Browse files
.gitattributes CHANGED
@@ -1,5 +1,5 @@
1
- *.wav filter=lfs diff=lfs merge=lfs -text
2
  *.mp3 filter=lfs diff=lfs merge=lfs -text
3
  *.flac filter=lfs diff=lfs merge=lfs -text
4
  *.pth filter=lfs diff=lfs merge=lfs -text
5
  *.bin filter=lfs diff=lfs merge=lfs -text
 
 
 
1
  *.mp3 filter=lfs diff=lfs merge=lfs -text
2
  *.flac filter=lfs diff=lfs merge=lfs -text
3
  *.pth filter=lfs diff=lfs merge=lfs -text
4
  *.bin filter=lfs diff=lfs merge=lfs -text
5
+ *.wav filter=lfs diff=lfs merge=lfs -text
Dockerfile CHANGED
@@ -3,20 +3,20 @@ FROM python:3.10-slim
3
  # Set working directory
4
  WORKDIR /app
5
 
6
- # Install system dependencies
7
  # Install system dependencies
8
  RUN apt-get update && apt-get install -y \
9
- ffmpeg \
10
  git \
11
  curl \
12
- espeak-ng \
 
13
  && rm -rf /var/lib/apt/lists/*
14
 
15
- # Copy application files
 
16
  COPY . .
17
 
18
- # Install Python dependencies
19
- RUN pip install --no-cache-dir -r requirements.txt
20
 
21
  # Create necessary directories
22
  RUN mkdir -p uploads temp_dir
@@ -24,5 +24,5 @@ RUN mkdir -p uploads temp_dir
24
  # Expose port
25
  EXPOSE 7860
26
 
27
- # Run only the Flask app (worker starts automatically on first upload)
28
- CMD ["python", "app.py"]
 
3
  # Set working directory
4
  WORKDIR /app
5
 
 
6
  # Install system dependencies
7
  RUN apt-get update && apt-get install -y \
 
8
  git \
9
  curl \
10
+ build-essential \
11
+ ffmpeg \
12
  && rm -rf /var/lib/apt/lists/*
13
 
14
+ # Copy project files
15
+ COPY pyproject.toml .
16
  COPY . .
17
 
18
+ # Install dependencies and project
19
+ RUN pip install --no-cache-dir .
20
 
21
  # Create necessary directories
22
  RUN mkdir -p uploads temp_dir
 
24
  # Expose port
25
  EXPOSE 7860
26
 
27
+ # Run the FastAPI app (worker starts automatically on first upload)
28
+ CMD ["python", "run.py"]
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: TTS Text-to-Speech Generator
3
  emoji: 🎵
4
  colorFrom: blue
5
  colorTo: purple
@@ -8,12 +8,12 @@ pinned: false
8
  license: mit
9
  ---
10
 
11
- # TTS Text-to-Speech Generator
12
 
13
  A Python-based text-to-speech service with a neobrutalist web interface.
14
 
15
  ## Features
16
- - 📝 Text-to-Speech generation
17
  - 🤖 Multiple voices and speeds
18
  - 💾 SQLite database for queue management
19
  - 🎨 Neobrutalist UI with smooth animations
 
1
  ---
2
+ title: TTS Text-to-Speech
3
  emoji: 🎵
4
  colorFrom: blue
5
  colorTo: purple
 
8
  license: mit
9
  ---
10
 
11
+ # TTS Text-to-Speech
12
 
13
  A Python-based text-to-speech service with a neobrutalist web interface.
14
 
15
  ## Features
16
+ - 📝 Text-to-Speech
17
  - 🤖 Multiple voices and speeds
18
  - 💾 SQLite database for queue management
19
  - 🎨 Neobrutalist UI with smooth animations
app.py DELETED
@@ -1,434 +0,0 @@
1
- from flask import Flask, request, jsonify, send_from_directory, send_file
2
- from flask_cors import CORS
3
- import sqlite3
4
- import os
5
- import uuid
6
- from datetime import datetime, timedelta
7
- from werkzeug.utils import secure_filename
8
- import threading
9
- import subprocess
10
- import time
11
- import shutil
12
-
13
- app = Flask(__name__)
14
- CORS(app)
15
-
16
- UPLOAD_FOLDER = 'uploads'
17
- os.makedirs(UPLOAD_FOLDER, exist_ok=True)
18
- os.makedirs('temp_dir', exist_ok=True)
19
-
20
- # Worker state
21
- worker_thread = None
22
- worker_running = False
23
-
24
- # Cleanup settings
25
- RETENTION_DAYS = 10 # Delete entries older than this many days
26
-
27
- def init_db():
28
- conn = sqlite3.connect('tts_tasks.db')
29
- c = conn.cursor()
30
- c.execute('''CREATE TABLE IF NOT EXISTS tasks
31
- (id TEXT PRIMARY KEY,
32
- text TEXT NOT NULL,
33
- voice TEXT,
34
- speed REAL,
35
- status TEXT NOT NULL,
36
- output_file TEXT,
37
- created_at TEXT NOT NULL,
38
- processed_at TEXT,
39
- error TEXT,
40
- progress INTEGER DEFAULT 0,
41
- progress_text TEXT,
42
- hide_from_ui INTEGER DEFAULT 0)'''
43
- )
44
-
45
- conn.commit()
46
- conn.close()
47
-
48
- def cleanup_old_entries():
49
- """Delete entries older than RETENTION_DAYS along with their audio files"""
50
- cutoff_date = (datetime.now() - timedelta(days=RETENTION_DAYS)).isoformat()
51
-
52
- print(f"\n🧹 Running cleanup for entries older than {RETENTION_DAYS} days...")
53
-
54
- try:
55
- conn = sqlite3.connect('tts_tasks.db')
56
- conn.row_factory = sqlite3.Row
57
- c = conn.cursor()
58
-
59
- # Find old entries
60
- c.execute('SELECT id, output_file FROM tasks WHERE created_at < ?', (cutoff_date,))
61
- old_entries = c.fetchall()
62
-
63
- if not old_entries:
64
- print(" No old entries to clean up.")
65
- conn.close()
66
- return
67
-
68
- deleted_count = 0
69
- for entry in old_entries:
70
- task_id = entry['id']
71
- output_file = entry['output_file']
72
-
73
- # Delete audio file if it exists
74
- if output_file:
75
- file_path = os.path.join(UPLOAD_FOLDER, output_file)
76
- if os.path.exists(file_path):
77
- os.remove(file_path)
78
- print(f" 🗑️ Deleted audio: {output_file}")
79
-
80
- # Delete database entry
81
- c.execute('DELETE FROM tasks WHERE id = ?', (task_id,))
82
- deleted_count += 1
83
-
84
- conn.commit()
85
- conn.close()
86
-
87
- print(f" ✅ Cleaned up {deleted_count} old entries.\n")
88
-
89
- except Exception as e:
90
- print(f" ⚠️ Cleanup error: {str(e)}\n")
91
-
92
- def start_worker():
93
- """Start the worker thread if not already running"""
94
- global worker_thread, worker_running
95
-
96
- if not worker_running:
97
- worker_running = True
98
- worker_thread = threading.Thread(target=worker_loop, daemon=True)
99
- worker_thread.start()
100
- print("✅ Worker thread started")
101
-
102
- def worker_loop():
103
- """Main worker loop that processes TTS tasks"""
104
- print("🤖 TTS Worker started. Monitoring for new tasks...")
105
-
106
- CWD = "./"
107
- PYTHON_PATH = "python3" # Or just python
108
- POLL_INTERVAL = 2 # seconds
109
-
110
- while worker_running:
111
- try:
112
- # Get next unprocessed task
113
- conn = sqlite3.connect('tts_tasks.db')
114
- conn.row_factory = sqlite3.Row
115
- c = conn.cursor()
116
- c.execute('''SELECT * FROM tasks
117
- WHERE status = 'not_started'
118
- ORDER BY created_at ASC
119
- LIMIT 1''')
120
- row = c.fetchone()
121
- conn.close()
122
-
123
- if row:
124
- task_id = row['id']
125
- text = row['text']
126
- voice = row['voice'] or '4' # Default voice index (American Male)
127
- speed = row['speed'] or 1.0
128
-
129
- # Run cleanup before processing each task
130
- cleanup_old_entries()
131
-
132
- print(f"\n{'='*60}")
133
- print(f"🎵 Processing Task: {task_id}")
134
- print(f"📝 Text: {text[:50]}...")
135
- print(f"{'='*60}")
136
-
137
- # Update status to processing
138
- update_status(task_id, 'processing')
139
-
140
- try:
141
- # Write text to content.txt
142
- with open('content.txt', 'w', encoding='utf-8') as f:
143
- f.write(text)
144
-
145
- # Run TTS command
146
- # python3 -m tts_runner.runner --model kokoro --voice <voice> --speed <speed>
147
- print(f"🔄 Running TTS...")
148
- command = [
149
- PYTHON_PATH, "-m", "tts_runner.runner",
150
- "--model", "chatterbox",
151
- "--voice", str(voice),
152
- "--speed", str(speed)
153
- ]
154
-
155
- # Run with output capture for progress tracking
156
- import re
157
- process = subprocess.Popen(
158
- command,
159
- stdout=subprocess.PIPE,
160
- stderr=subprocess.STDOUT,
161
- cwd=CWD,
162
- text=True,
163
- bufsize=1,
164
- env={
165
- **os.environ,
166
- 'PYTHONUNBUFFERED': '1',
167
- 'CUDA_LAUNCH_BLOCKING': '1'
168
- }
169
- )
170
-
171
- total_sentences = 0
172
- current_sentence = 0
173
-
174
- for line in process.stdout:
175
- print(line, end='') # Echo to console
176
-
177
- # Parse "Sentence X processed" lines
178
- match = re.search(r'Sentence\s+(\d+)\s+processed', line)
179
- if match:
180
- current_sentence = int(match.group(1))
181
- # Try to get total from "Combining X audio files"
182
- if total_sentences > 0:
183
- progress = int((current_sentence / total_sentences) * 100)
184
- update_progress(task_id, progress, f"Processing sentence {current_sentence}/{total_sentences}")
185
-
186
- # Parse "Combining X audio files" to get total count
187
- combine_match = re.search(r'Combining\s+(\d+)\s+audio\s+files', line)
188
- if combine_match:
189
- total_sentences = int(combine_match.group(1))
190
- update_progress(task_id, 90, "Combining audio files...")
191
-
192
- # Parse "Processing text sentences..." as start
193
- if 'Processing text sentences' in line:
194
- update_progress(task_id, 5, "Processing text sentences...")
195
-
196
- # Parse model loading
197
- if 'Model loaded successfully' in line:
198
- update_progress(task_id, 10, "Model loaded, starting TTS...")
199
-
200
- process.wait()
201
- if process.returncode != 0:
202
- raise Exception(f"TTS process failed with return code {process.returncode}")
203
-
204
- # Check for output file
205
- output_filename = "output_audio.wav"
206
- if os.path.exists(output_filename):
207
- # Move to uploads folder
208
- target_filename = f"{task_id}.wav"
209
- target_path = os.path.join(UPLOAD_FOLDER, target_filename)
210
- shutil.move(output_filename, target_path)
211
-
212
- print(f"✅ Successfully processed: {target_filename}")
213
-
214
- # Update database with success
215
- update_status(task_id, 'completed', output_file=target_filename)
216
- else:
217
- raise Exception("Output audio file not found")
218
-
219
- except Exception as e:
220
- print(f"❌ Failed to process: {task_id}")
221
- print(f"Error: {str(e)}")
222
- update_status(task_id, 'failed', error=str(e))
223
-
224
- else:
225
- # No tasks to process, sleep for a bit
226
- time.sleep(POLL_INTERVAL)
227
-
228
- except Exception as e:
229
- print(f"⚠️ Worker error: {str(e)}")
230
- time.sleep(POLL_INTERVAL)
231
-
232
- def update_progress(task_id, progress, progress_text=None):
233
- """Update the progress of a task"""
234
- conn = sqlite3.connect('tts_tasks.db')
235
- c = conn.cursor()
236
- c.execute('UPDATE tasks SET progress = ?, progress_text = ? WHERE id = ?',
237
- (progress, progress_text, task_id))
238
- conn.commit()
239
- conn.close()
240
-
241
- def update_status(task_id, status, output_file=None, error=None):
242
- """Update the status of a task in the database"""
243
- conn = sqlite3.connect('tts_tasks.db')
244
- c = conn.cursor()
245
-
246
- if status == 'completed':
247
- c.execute('''UPDATE tasks
248
- SET status = ?, output_file = ?, processed_at = ?, progress = 100, progress_text = 'Completed'
249
- WHERE id = ?''',
250
- (status, output_file, datetime.now().isoformat(), task_id))
251
- elif status == 'failed':
252
- c.execute('''UPDATE tasks
253
- SET status = ?, error = ?, processed_at = ?, progress_text = 'Failed'
254
- WHERE id = ?''',
255
- (status, str(error), datetime.now().isoformat(), task_id))
256
- else:
257
- c.execute('UPDATE tasks SET status = ? WHERE id = ?', (status, task_id))
258
-
259
- conn.commit()
260
- conn.close()
261
-
262
- @app.route('/')
263
- def index():
264
- return send_from_directory('.', 'index.html')
265
-
266
- @app.route('/api/generate', methods=['POST'])
267
- def generate_audio():
268
- data = request.json
269
- if not data or 'text' not in data:
270
- return jsonify({'error': 'No text provided'}), 400
271
-
272
- text = data['text']
273
- voice = data.get('voice', '4')
274
- speed = data.get('speed', 1.0)
275
- hide_from_ui = 1 if data.get('hide_from_ui') else 0
276
-
277
- if not text.strip():
278
- return jsonify({'error': 'Text cannot be empty'}), 400
279
-
280
- task_id = str(uuid.uuid4())
281
-
282
- conn = sqlite3.connect('tts_tasks.db')
283
- c = conn.cursor()
284
- c.execute('''INSERT INTO tasks
285
- (id, text, voice, speed, status, created_at, hide_from_ui)
286
- VALUES (?, ?, ?, ?, ?, ?, ?)''',
287
- (task_id, text, voice, speed, 'not_started', datetime.now().isoformat(), hide_from_ui))
288
- conn.commit()
289
- conn.close()
290
-
291
- # Start worker on first request
292
- start_worker()
293
-
294
- return jsonify({
295
- 'id': task_id,
296
- 'status': 'not_started',
297
- 'message': 'Task queued successfully'
298
- }), 201
299
-
300
- @app.route('/api/files', methods=['GET'])
301
- def get_files():
302
- conn = sqlite3.connect('tts_tasks.db')
303
- conn.row_factory = sqlite3.Row
304
- c = conn.cursor()
305
- c.execute('SELECT * FROM tasks WHERE hide_from_ui = 0 OR hide_from_ui IS NULL ORDER BY created_at DESC')
306
- rows = c.fetchall()
307
-
308
- # Get queue order for not_started tasks (oldest first = position 1)
309
- c.execute('''SELECT id FROM tasks
310
- WHERE status = 'not_started'
311
- ORDER BY created_at ASC''')
312
- queue_order = [r['id'] for r in c.fetchall()]
313
-
314
- # Check if any task is currently processing
315
- c.execute('SELECT COUNT(*) as count FROM tasks WHERE status = "processing"')
316
- processing_count = c.fetchone()['count']
317
-
318
- conn.close()
319
-
320
- # Average processing time in seconds (can be adjusted based on actual metrics)
321
- AVG_PROCESSING_TIME = 30
322
-
323
- files = []
324
- for row in rows:
325
- file_data = {
326
- 'id': row['id'],
327
- 'text': row['text'],
328
- 'status': row['status'],
329
- 'output_file': row['output_file'],
330
- 'created_at': row['created_at'],
331
- 'processed_at': row['processed_at'],
332
- 'error': row['error'],
333
- 'progress': row['progress'] or 0,
334
- 'progress_text': row['progress_text']
335
- }
336
-
337
- # Add queue position for not_started tasks
338
- if row['status'] == 'not_started' and row['id'] in queue_order:
339
- queue_position = queue_order.index(row['id']) + 1 # 1-indexed
340
- file_data['queue_position'] = queue_position
341
- # Estimated time = (position - 1 + processing_count) * avg_time
342
- # If something is processing, add that to the wait
343
- tasks_ahead = queue_position - 1 + processing_count
344
- file_data['estimated_start_seconds'] = tasks_ahead * AVG_PROCESSING_TIME
345
-
346
- files.append(file_data)
347
-
348
- return jsonify(files)
349
-
350
- @app.route('/api/files/<task_id>', methods=['GET'])
351
- def get_file(task_id):
352
- conn = sqlite3.connect('tts_tasks.db')
353
- conn.row_factory = sqlite3.Row
354
- c = conn.cursor()
355
- c.execute('SELECT * FROM tasks WHERE id = ?', (task_id,))
356
- row = c.fetchone()
357
-
358
- if row is None:
359
- conn.close()
360
- return jsonify({'error': 'Task not found'}), 404
361
-
362
- # Get queue order for not_started tasks (oldest first = position 1)
363
- c.execute('''SELECT id FROM tasks
364
- WHERE status = 'not_started'
365
- ORDER BY created_at ASC''')
366
- queue_order = [r['id'] for r in c.fetchall()]
367
-
368
- # Check if any task is currently processing
369
- c.execute('SELECT COUNT(*) as count FROM tasks WHERE status = "processing"')
370
- processing_count = c.fetchone()['count']
371
-
372
- conn.close()
373
-
374
- # Average processing time in seconds
375
- AVG_PROCESSING_TIME = 30
376
-
377
- file_data = {
378
- 'id': row['id'],
379
- 'text': row['text'],
380
- 'status': row['status'],
381
- 'output_file': row['output_file'],
382
- 'created_at': row['created_at'],
383
- 'processed_at': row['processed_at'],
384
- 'error': row['error'],
385
- 'progress': row['progress'] or 0,
386
- 'progress_text': row['progress_text']
387
- }
388
-
389
- # Add queue position for not_started tasks
390
- if row['status'] == 'not_started' and row['id'] in queue_order:
391
- queue_position = queue_order.index(row['id']) + 1 # 1-indexed
392
- file_data['queue_position'] = queue_position
393
- tasks_ahead = queue_position - 1 + processing_count
394
- file_data['estimated_start_seconds'] = tasks_ahead * AVG_PROCESSING_TIME
395
-
396
- return jsonify(file_data)
397
-
398
- @app.route('/api/download/<task_id>', methods=['GET'])
399
- def download_file(task_id):
400
- conn = sqlite3.connect('tts_tasks.db')
401
- conn.row_factory = sqlite3.Row
402
- c = conn.cursor()
403
- c.execute('SELECT * FROM tasks WHERE id = ?', (task_id,))
404
- row = c.fetchone()
405
- conn.close()
406
-
407
- if row is None or not row['output_file']:
408
- return jsonify({'error': 'File not found'}), 404
409
-
410
- file_path = os.path.join(UPLOAD_FOLDER, row['output_file'])
411
- if not os.path.exists(file_path):
412
- return jsonify({'error': 'File missing on server'}), 404
413
-
414
- return send_file(file_path, as_attachment=True, download_name=f"tts_{task_id}.wav")
415
-
416
- @app.route('/health', methods=['GET'])
417
- def health():
418
- return jsonify({
419
- 'status': 'healthy',
420
- 'service': 'tts-generator',
421
- 'worker_running': worker_running
422
- })
423
-
424
- if __name__ == '__main__':
425
- init_db()
426
- print("\n" + "="*60)
427
- print("🚀 TTS Generator API Server")
428
- print("="*60)
429
- print("📌 Worker will start automatically on first request")
430
- print("="*60 + "\n")
431
-
432
- # Use PORT environment variable for Hugging Face compatibility
433
- port = int(os.environ.get('PORT', 7860))
434
- app.run(debug=False, host='0.0.0.0', port=port)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/__init__.py ADDED
File without changes
app/api/__init__.py ADDED
File without changes
app/api/routes.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, UploadFile, File, Form, HTTPException
2
+ from fastapi.responses import FileResponse, JSONResponse
3
+ import os
4
+ import uuid
5
+ import aiofiles
6
+ from app.core.config import settings
7
+ from custom_logger import logger_config as logger
8
+ from app.db import crud
9
+ from app.services.worker import start_worker, is_worker_running
10
+
11
+ router = APIRouter()
12
+
13
+ @router.get("/")
14
+ async def index():
15
+ return FileResponse('index.html')
16
+
17
+ @router.post("/api/tasks/upload")
18
+ async def create_task(
19
+ text: str = Form(None),
20
+ file: UploadFile = File(None),
21
+ voice: str = Form("4"),
22
+ speed: float = Form(1.0),
23
+ hide_from_ui: str = Form("")
24
+ ):
25
+ task_text = ""
26
+
27
+ if text:
28
+ task_text = text
29
+ elif file:
30
+ try:
31
+ content = await file.read()
32
+ task_text = content.decode('utf-8')
33
+ except Exception as e:
34
+ logger.error(f"Error reading uploaded file: {e}")
35
+ raise HTTPException(status_code=400, detail="Could not read file content")
36
+
37
+ if not task_text.strip():
38
+ raise HTTPException(status_code=400, detail="No text provided")
39
+
40
+ task_id = str(uuid.uuid4())
41
+ filename = file.filename if file else f"{task_text[:20]}..."
42
+ hide_from_ui_val = 1 if hide_from_ui.lower() in ['true', '1'] else 0
43
+
44
+ await crud.insert_task(task_id, filename, task_text, voice, speed, 'not_started', hide_from_ui_val)
45
+ await start_worker()
46
+
47
+ return JSONResponse(status_code=201, content={
48
+ 'id': task_id,
49
+ 'filename': filename,
50
+ 'status': 'not_started',
51
+ 'message': 'Task created successfully'
52
+ })
53
+
54
+ @router.get("/api/tasks")
55
+ async def get_tasks():
56
+ rows, queue_ids, processing_count, avg_time = await crud.get_all_tasks()
57
+
58
+ tasks = []
59
+ for row in rows:
60
+ queue_position = None
61
+ estimated_start_seconds = None
62
+
63
+ if row['status'] == 'not_started' and row['id'] in queue_ids:
64
+ queue_position = queue_ids.index(row['id']) + 1
65
+ tasks_ahead = queue_position - 1 + processing_count
66
+ estimated_start_seconds = round(tasks_ahead * avg_time)
67
+
68
+ tasks.append({
69
+ 'id': row['id'],
70
+ 'filename': row['filename'],
71
+ 'status': row['status'],
72
+ 'result': "HIDDEN_IN_LIST_VIEW",
73
+ 'created_at': row['created_at'],
74
+ 'processed_at': row['processed_at'],
75
+ 'progress': row['progress'] or 0,
76
+ 'progress_text': row['progress_text'],
77
+ 'queue_position': queue_position,
78
+ 'estimated_start_seconds': estimated_start_seconds
79
+ })
80
+
81
+ return tasks
82
+
83
+ @router.get("/api/tasks/{task_id}")
84
+ async def get_task(task_id: str):
85
+ result = await crud.get_task_by_id(task_id)
86
+ if not result:
87
+ raise HTTPException(status_code=404, detail="Task not found")
88
+
89
+ row, queue_position, estimated_start_seconds = result
90
+
91
+ return {
92
+ 'id': row['id'],
93
+ 'filename': row['filename'],
94
+ 'text': row['text'],
95
+ 'status': row['status'],
96
+ 'result': row['text'], # result maps to text for detail view
97
+ 'output_file': row['output_file'],
98
+ 'created_at': row['created_at'],
99
+ 'processed_at': row['processed_at'],
100
+ 'progress': row['progress'] or 0,
101
+ 'progress_text': row['progress_text'],
102
+ 'queue_position': queue_position,
103
+ 'estimated_start_seconds': estimated_start_seconds
104
+ }
105
+
106
+ @router.get("/api/download/{task_id}")
107
+ async def download_task(task_id: str):
108
+ result = await crud.get_task_by_id(task_id)
109
+ if not result or not result[0]['output_file']:
110
+ raise HTTPException(status_code=404, detail="Audio file not found")
111
+
112
+ row = result[0]
113
+ filepath = os.path.join(settings.UPLOAD_FOLDER, row['output_file'])
114
+ if not os.path.exists(filepath):
115
+ raise HTTPException(status_code=404, detail="File missing on server")
116
+
117
+ return FileResponse(filepath, media_type="audio/wav", filename=f"tts_{task_id}.wav")
118
+
119
+ @router.get("/health")
120
+ async def health():
121
+ return {
122
+ 'status': 'healthy',
123
+ 'service': 'tts-runner',
124
+ 'worker_running': is_worker_running()
125
+ }
app/core/__init__.py ADDED
File without changes
app/core/config.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ class Config:
4
+ PORT = int(os.environ.get('PORT', 7860))
5
+ UPLOAD_FOLDER = 'uploads'
6
+ TEMP_DIR = 'temp_dir'
7
+ DATABASE_FILE = 'tts_results.db'
8
+ ALLOWED_EXTENSIONS = {'txt', 'md', 'text'}
9
+
10
+ CWD = "./"
11
+ PYTHON_PATH = "python3"
12
+ POLL_INTERVAL = 2
13
+
14
+ # TTS Specifics
15
+ RETENTION_DAYS = 10
16
+
17
+ settings = Config()
18
+
19
+ os.makedirs(settings.UPLOAD_FOLDER, exist_ok=True)
20
+ os.makedirs(settings.TEMP_DIR, exist_ok=True)
app/db/__init__.py ADDED
File without changes
app/db/crud.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import aiosqlite
2
+ import os
3
+ from datetime import datetime, timedelta
4
+ from app.core.config import settings
5
+ from custom_logger import logger_config as logger
6
+
7
+ async def insert_task(task_id: str, filename: str, text: str, voice: str, speed: float, status: str, hide_from_ui: int):
8
+ async with aiosqlite.connect(settings.DATABASE_FILE) as db:
9
+ await db.execute('''INSERT INTO tasks
10
+ (id, filename, text, voice, speed, status, created_at, hide_from_ui)
11
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
12
+ (task_id, filename, text, voice, speed, status, datetime.now().isoformat(), hide_from_ui))
13
+ await db.commit()
14
+ logger.debug(f"Inserted task {task_id} into database.")
15
+
16
+ async def update_status(task_id: str, status: str, output_file: str = None, error: str = None):
17
+ async with aiosqlite.connect(settings.DATABASE_FILE) as db:
18
+ if status == 'completed':
19
+ await db.execute('''UPDATE tasks
20
+ SET status = ?, output_file = ?, processed_at = ?, progress = 100, progress_text = 'Completed'
21
+ WHERE id = ?''',
22
+ (status, output_file, datetime.now().isoformat(), task_id))
23
+ logger.info(f"Task ID {task_id} marked as completed.")
24
+ elif status == 'failed':
25
+ await db.execute('''UPDATE tasks
26
+ SET status = ?, error = ?, processed_at = ?, progress_text = 'Failed'
27
+ WHERE id = ?''',
28
+ (status, str(error), datetime.now().isoformat(), task_id))
29
+ logger.error(f"Task ID {task_id} marked as failed. Error: {error}")
30
+ else:
31
+ await db.execute('UPDATE tasks SET status = ? WHERE id = ?', (status, task_id))
32
+ logger.debug(f"Task ID {task_id} status updated to {status}.")
33
+ await db.commit()
34
+
35
+ async def update_progress(task_id: str, progress: int, progress_text: str = None):
36
+ async with aiosqlite.connect(settings.DATABASE_FILE) as db:
37
+ await db.execute('UPDATE tasks SET progress = ?, progress_text = ? WHERE id = ?',
38
+ (progress, progress_text, task_id))
39
+ await db.commit()
40
+ logger.debug(f"Task ID {task_id} progress updated to {progress}% ({progress_text}).")
41
+
42
+ async def get_next_not_started():
43
+ async with aiosqlite.connect(settings.DATABASE_FILE) as db:
44
+ db.row_factory = aiosqlite.Row
45
+ async with db.execute('''SELECT * FROM tasks
46
+ WHERE status = 'not_started'
47
+ ORDER BY created_at ASC
48
+ LIMIT 1''') as cursor:
49
+ return await cursor.fetchone()
50
+
51
+ async def cleanup_old_entries():
52
+ try:
53
+ async with aiosqlite.connect(settings.DATABASE_FILE) as db:
54
+ db.row_factory = aiosqlite.Row
55
+ cutoff_date = (datetime.now() - timedelta(days=settings.RETENTION_DAYS)).isoformat()
56
+
57
+ async with db.execute('''SELECT id, output_file FROM tasks
58
+ WHERE created_at < ?''', (cutoff_date,)) as cursor:
59
+ old_entries = await cursor.fetchall()
60
+
61
+ if old_entries:
62
+ deleted_files = 0
63
+ deleted_rows = 0
64
+
65
+ for entry in old_entries:
66
+ output_file = entry['output_file']
67
+ if output_file:
68
+ filepath = os.path.join(settings.UPLOAD_FOLDER, output_file)
69
+ if os.path.exists(filepath):
70
+ try:
71
+ os.remove(filepath)
72
+ deleted_files += 1
73
+ except Exception as e:
74
+ logger.warning(f"Failed to delete old file {filepath}: {e}")
75
+
76
+ async with db.execute('''DELETE FROM tasks WHERE created_at < ?''', (cutoff_date,)) as cursor:
77
+ deleted_rows = cursor.rowcount
78
+ await db.commit()
79
+
80
+ if deleted_rows > 0 or deleted_files > 0:
81
+ logger.info(f"Cleanup: Deleted {deleted_rows} old entries and {deleted_files} audio files")
82
+ except Exception as e:
83
+ logger.error(f"Cleanup error: {e}")
84
+
85
+ async def get_average_processing_time():
86
+ async with aiosqlite.connect(settings.DATABASE_FILE) as db:
87
+ db.row_factory = aiosqlite.Row
88
+ async with db.execute('''SELECT created_at, processed_at FROM tasks
89
+ WHERE status = 'completed' AND processed_at IS NOT NULL
90
+ ORDER BY processed_at DESC LIMIT 20''') as cursor:
91
+ completed_rows = await cursor.fetchall()
92
+
93
+ if not completed_rows:
94
+ return 30.0
95
+
96
+ total_seconds = 0
97
+ count = 0
98
+ for r in completed_rows:
99
+ try:
100
+ created = datetime.fromisoformat(r['created_at'])
101
+ processed = datetime.fromisoformat(r['processed_at'])
102
+ duration = (processed - created).total_seconds()
103
+ if duration > 0:
104
+ total_seconds += duration
105
+ count += 1
106
+ except:
107
+ continue
108
+
109
+ return total_seconds / count if count > 0 else 30.0
110
+
111
+ async def get_all_tasks():
112
+ async with aiosqlite.connect(settings.DATABASE_FILE) as db:
113
+ db.row_factory = aiosqlite.Row
114
+
115
+ avg_time = await get_average_processing_time()
116
+
117
+ async with db.execute('''SELECT id FROM tasks
118
+ WHERE status = 'not_started'
119
+ ORDER BY created_at ASC''') as cursor:
120
+ queue_ids = [row['id'] for row in await cursor.fetchall()]
121
+
122
+ async with db.execute('''SELECT COUNT(*) as count FROM tasks WHERE status = 'processing' ''') as cursor:
123
+ row = await cursor.fetchone()
124
+ processing_count = row['count']
125
+
126
+ async with db.execute('SELECT * FROM tasks WHERE hide_from_ui = 0 OR hide_from_ui IS NULL ORDER BY created_at DESC') as cursor:
127
+ rows = await cursor.fetchall()
128
+
129
+ return rows, queue_ids, processing_count, avg_time
130
+
131
+ async def get_task_by_id(task_id: str):
132
+ async with aiosqlite.connect(settings.DATABASE_FILE) as db:
133
+ db.row_factory = aiosqlite.Row
134
+ async with db.execute('SELECT * FROM tasks WHERE id = ?', (task_id,)) as cursor:
135
+ row = await cursor.fetchone()
136
+
137
+ if not row:
138
+ return None
139
+
140
+ queue_position = None
141
+ estimated_start_seconds = None
142
+
143
+ if row['status'] == 'not_started':
144
+ avg_time = await get_average_processing_time()
145
+
146
+ async with db.execute('''SELECT COUNT(*) as position FROM tasks
147
+ WHERE status = 'not_started' AND created_at < ?''',
148
+ (row['created_at'],)) as cursor:
149
+ position_row = await cursor.fetchone()
150
+ queue_position = position_row['position'] + 1
151
+
152
+ async with db.execute('''SELECT COUNT(*) as count FROM tasks WHERE status = 'processing' ''') as cursor:
153
+ count_row = await cursor.fetchone()
154
+ processing_count = count_row['count']
155
+
156
+ tasks_ahead = queue_position - 1 + processing_count
157
+ estimated_start_seconds = round(tasks_ahead * avg_time)
158
+
159
+ return row, queue_position, estimated_start_seconds
app/db/database.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import aiosqlite
2
+ from app.core.config import settings
3
+ from custom_logger import logger_config as logger
4
+
5
+ async def init_db():
6
+ logger.info(f"Initializing database at {settings.DATABASE_FILE}")
7
+ async with aiosqlite.connect(settings.DATABASE_FILE) as db:
8
+ await db.execute('''CREATE TABLE IF NOT EXISTS tasks
9
+ (id TEXT PRIMARY KEY,
10
+ filename TEXT,
11
+ text TEXT NOT NULL,
12
+ voice TEXT,
13
+ speed REAL,
14
+ status TEXT NOT NULL,
15
+ output_file TEXT,
16
+ created_at TEXT NOT NULL,
17
+ processed_at TEXT,
18
+ error TEXT,
19
+ progress INTEGER DEFAULT 0,
20
+ progress_text TEXT,
21
+ hide_from_ui INTEGER DEFAULT 0)'''
22
+ )
23
+ await db.commit()
24
+ logger.info("Database initialized successfully.")
app/main.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import asynccontextmanager
2
+ from fastapi import FastAPI
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from app.api.routes import router
5
+ from app.db.database import init_db
6
+ from custom_logger import logger_config as logger
7
+
8
+ @asynccontextmanager
9
+ async def lifespan(app: FastAPI):
10
+ logger.info("="*60)
11
+ logger.info("TTS Runner API Server Starting Up")
12
+ logger.info("="*60)
13
+ logger.info("Worker will start automatically on first request")
14
+ logger.info("Audio files will be retained for 10 days")
15
+ logger.info("="*60)
16
+
17
+ await init_db()
18
+ yield
19
+ logger.info("TTS Runner API Server Shutting Down")
20
+
21
+ app = FastAPI(title="TTS Runner API", version="2.0.0", lifespan=lifespan)
22
+
23
+ app.add_middleware(
24
+ CORSMiddleware,
25
+ allow_origins=["*"],
26
+ allow_credentials=True,
27
+ allow_methods=["*"],
28
+ allow_headers=["*"],
29
+ )
30
+
31
+ app.include_router(router)
app/services/__init__.py ADDED
File without changes
app/services/worker.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ import subprocess
4
+ import re
5
+ import shutil
6
+ from app.core.config import settings
7
+ from app.db import crud
8
+ from custom_logger import logger_config as logger
9
+
10
+ _worker_task = None
11
+
12
+ async def start_worker():
13
+ global _worker_task
14
+ if _worker_task is None or _worker_task.done():
15
+ _worker_task = asyncio.create_task(worker_loop())
16
+ logger.info("TTS Worker background task started")
17
+
18
+ def is_worker_running():
19
+ return _worker_task is not None and not _worker_task.done()
20
+
21
+ async def worker_loop():
22
+ global _worker_task
23
+ logger.info("TTS Worker loop started. Monitoring for new tasks...")
24
+
25
+ while True:
26
+ try:
27
+ # Cleanup old entries
28
+ await crud.cleanup_old_entries()
29
+
30
+ # Get next unprocessed task
31
+ task = await crud.get_next_not_started()
32
+
33
+ if task:
34
+ task_id = task['id']
35
+ text = task['text']
36
+ filename = task['filename']
37
+ voice = task['voice'] or '4'
38
+ speed = task['speed'] or 1.0
39
+
40
+ logger.info(f"\n{'='*60}\nProcessing: {filename}\nID: {task_id}\n{'='*60}")
41
+ await crud.update_status(task_id, 'processing')
42
+
43
+ try:
44
+ await crud.update_progress(task_id, 5, "Initializing TTS...")
45
+
46
+ # Write text to content.txt
47
+ content_path = os.path.join(settings.CWD, 'content.txt')
48
+ with open(content_path, 'w', encoding='utf-8') as f:
49
+ f.write(text)
50
+
51
+ # Run TTS command
52
+ command = [
53
+ settings.PYTHON_PATH, "-m", "tts_runner.runner",
54
+ "--model", "chatterbox",
55
+ "--voice", str(voice),
56
+ "--speed", str(speed)
57
+ ]
58
+
59
+ logger.debug(f"Executing command: {' '.join(command)}")
60
+
61
+ process = await asyncio.create_subprocess_exec(
62
+ *command,
63
+ stdout=subprocess.PIPE,
64
+ stderr=subprocess.STDOUT,
65
+ cwd=settings.CWD,
66
+ env={
67
+ **os.environ,
68
+ 'PYTHONUNBUFFERED': '1',
69
+ 'CUDA_LAUNCH_BLOCKING': '1'
70
+ }
71
+ )
72
+
73
+ total_sentences = 0
74
+ current_sentence = 0
75
+
76
+ async for line in process.stdout:
77
+ line_str = line.decode('utf-8', errors='replace').strip()
78
+ if line_str:
79
+ logger.info(f"[TTS] {line_str}")
80
+
81
+ # Parse "Sentence X processed" lines
82
+ match = re.search(r'Sentence\s+(\d+)\s+processed', line_str)
83
+ if match:
84
+ current_sentence = int(match.group(1))
85
+ if total_sentences > 0:
86
+ progress = int((current_sentence / total_sentences) * 90)
87
+ await crud.update_progress(task_id, max(progress, 10), f"Processing sentence {current_sentence}/{total_sentences}")
88
+
89
+ # Parse "Combining X audio files"
90
+ combine_match = re.search(r'Combining\s+(\d+)\s+audio\s+files', line_str)
91
+ if combine_match:
92
+ total_sentences = int(combine_match.group(1))
93
+ await crud.update_progress(task_id, 90, "Combining audio files...")
94
+
95
+ if 'Model loaded successfully' in line_str:
96
+ await crud.update_progress(task_id, 10, "Model ready, starting synthesis...")
97
+
98
+ await process.wait()
99
+
100
+ if process.returncode == 0:
101
+ output_filename = "output_audio.wav"
102
+ if os.path.exists(output_filename):
103
+ target_filename = f"{task_id}.wav"
104
+ target_path = os.path.join(settings.UPLOAD_FOLDER, target_filename)
105
+ shutil.move(output_filename, target_path)
106
+
107
+ logger.success(f"Successfully processed: {filename}")
108
+ await crud.update_status(task_id, 'completed', output_file=target_filename)
109
+ else:
110
+ raise Exception("Output audio file not found")
111
+ else:
112
+ raise Exception(f"TTS process failed with return code {process.returncode}")
113
+
114
+ except Exception as e:
115
+ logger.error(f"Failed to process {filename}: {str(e)}")
116
+ await crud.update_status(task_id, 'failed', error=str(e))
117
+ else:
118
+ await asyncio.sleep(settings.POLL_INTERVAL)
119
+
120
+ except Exception as e:
121
+ logger.error(f"Worker error: {str(e)}")
122
+ await asyncio.sleep(settings.POLL_INTERVAL)
index.html CHANGED
@@ -2,987 +2,1041 @@
2
  <html lang="en">
3
 
4
  <head>
5
- <meta charset="UTF-8">
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
- <title>TTS Generator</title>
8
- <style>
9
- * {
10
- margin: 0;
11
- padding: 0;
12
- box-sizing: border-box;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  }
 
14
 
15
- :root {
16
- --bg: #0a0e27;
17
- --surface: #141b3d;
18
- --primary: #00ff88;
19
- --secondary: #ff00ff;
20
- --accent: #00d4ff;
21
- --error: #ff1744;
22
- --text: #ffffff;
23
- --border: 4px;
24
- }
 
 
25
 
 
 
26
  body {
27
- font-family: 'Space Grotesk', 'Courier New', monospace;
28
- background: var(--bg);
29
- color: var(--text);
30
- min-height: 100vh;
31
- overflow-x: hidden;
32
- position: relative;
33
  }
34
 
35
- body::before {
36
- content: '';
37
- position: fixed;
38
- top: 0;
39
- left: 0;
40
- width: 100%;
41
- height: 100%;
42
- background:
43
- radial-gradient(circle at 20% 50%, rgba(0, 255, 136, 0.1) 0%, transparent 50%),
44
- radial-gradient(circle at 80% 80%, rgba(255, 0, 255, 0.1) 0%, transparent 50%),
45
- radial-gradient(circle at 40% 20%, rgba(0, 212, 255, 0.1) 0%, transparent 50%);
46
- pointer-events: none;
47
- z-index: 0;
48
  }
49
 
50
- .container {
51
- max-width: 1400px;
52
- margin: 0 auto;
53
- padding: 2rem;
54
- position: relative;
55
- z-index: 1;
56
  }
57
 
58
- header {
59
- text-align: center;
60
- margin-bottom: 3rem;
61
- animation: slideDown 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55);
62
  }
63
 
64
- @keyframes slideDown {
65
- from {
66
- opacity: 0;
67
- transform: translateY(-50px);
68
- }
69
-
70
- to {
71
- opacity: 1;
72
- transform: translateY(0);
73
- }
74
  }
75
 
76
- h1 {
77
- font-size: clamp(2rem, 5vw, 4rem);
78
- font-weight: 900;
79
- background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 50%, var(--secondary) 100%);
80
- -webkit-background-clip: text;
81
- -webkit-text-fill-color: transparent;
82
- background-clip: text;
83
- text-transform: uppercase;
84
- letter-spacing: -2px;
85
- margin-bottom: 1rem;
86
- position: relative;
87
- display: inline-block;
88
  }
89
 
90
- h1::after {
91
- content: '';
92
- position: absolute;
93
- bottom: -10px;
94
- left: 50%;
95
- transform: translateX(-50%);
96
- width: 60%;
97
- height: 6px;
98
- background: linear-gradient(90deg, transparent, var(--primary), transparent);
99
- animation: glow 2s ease-in-out infinite;
100
  }
101
 
102
- @keyframes glow {
103
-
104
- 0%,
105
- 100% {
106
- opacity: 0.5;
107
- }
108
 
109
- 50% {
110
- opacity: 1;
111
- }
 
 
 
112
  }
113
 
114
- .subtitle {
115
- font-size: 1.2rem;
116
- color: var(--accent);
117
- letter-spacing: 2px;
118
  }
119
 
120
- .input-section {
121
- background: var(--surface);
122
- border: var(--border) solid var(--primary);
123
- box-shadow: 8px 8px 0 var(--primary);
124
- padding: 2rem;
125
- margin-bottom: 3rem;
126
- position: relative;
127
- transition: all 0.3s ease;
128
- animation: slideUp 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55) 0.2s both;
129
  }
130
 
131
- @keyframes slideUp {
132
  from {
133
- opacity: 0;
134
- transform: translateY(50px);
135
  }
136
 
137
  to {
138
- opacity: 1;
139
- transform: translateY(0);
140
  }
141
  }
142
 
143
- .input-section:hover {
144
- transform: translate(-2px, -2px);
145
- box-shadow: 12px 12px 0 var(--primary);
146
- }
147
-
148
- textarea {
149
- width: 100%;
150
- height: 150px;
151
- background: rgba(0, 212, 255, 0.05);
152
- border: 3px solid var(--accent);
153
- color: var(--text);
154
- padding: 1rem;
155
- font-family: inherit;
156
- font-size: 1.1rem;
157
- resize: vertical;
158
- margin-bottom: 1.5rem;
159
- transition: all 0.3s ease;
160
- }
161
-
162
- textarea:focus {
163
- outline: none;
164
- border-color: var(--primary);
165
- background: rgba(0, 255, 136, 0.05);
166
- }
167
-
168
- .controls {
169
- display: flex;
170
- gap: 1rem;
171
- margin-bottom: 1.5rem;
172
- flex-wrap: wrap;
173
- }
174
-
175
- .control-group {
176
- flex: 1;
177
- min-width: 200px;
178
- }
179
 
180
- label {
181
- display: block;
182
- margin-bottom: 0.5rem;
183
- color: var(--accent);
184
- font-weight: bold;
185
- }
186
 
187
- select,
188
- input[type="number"] {
189
- width: 100%;
190
- padding: 0.8rem;
191
- background: var(--bg);
192
- border: 2px solid var(--accent);
193
- color: var(--text);
194
- font-family: inherit;
195
- font-size: 1rem;
196
- }
197
-
198
- .btn {
199
- background: var(--primary);
200
- color: var(--bg);
201
- border: var(--border) solid var(--bg);
202
- padding: 1rem 2rem;
203
- font-size: 1.1rem;
204
- font-weight: 900;
205
- text-transform: uppercase;
206
- cursor: pointer;
207
- transition: all 0.2s ease;
208
- box-shadow: 4px 4px 0 var(--bg);
209
- letter-spacing: 1px;
210
- position: relative;
211
- width: 100%;
212
  }
213
 
214
- .btn:hover:not(:disabled) {
215
- transform: translate(-2px, -2px);
216
- box-shadow: 6px 6px 0 var(--bg);
217
  }
218
 
219
- .btn:active:not(:disabled) {
220
- transform: translate(2px, 2px);
221
- box-shadow: 2px 2px 0 var(--bg);
222
  }
223
 
224
- .btn:disabled {
225
- opacity: 0.6;
226
- cursor: not-allowed;
227
  }
228
 
229
- .btn-secondary {
230
- background: var(--accent);
231
  }
232
 
233
- .btn-small {
234
- padding: 0.5rem 1rem;
235
- font-size: 0.85rem;
236
- box-shadow: 3px 3px 0 var(--bg);
237
- text-decoration: none;
238
- display: inline-block;
239
- color: var(--bg);
240
  }
241
 
242
- .btn-small:hover:not(:disabled) {
243
- box-shadow: 4px 4px 0 var(--bg);
244
- transform: translate(-2px, -2px);
245
  }
246
 
247
- .table-section {
248
- animation: slideUp 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55) 0.4s both;
 
 
 
 
 
 
 
 
249
  }
250
 
251
- .table-wrapper {
252
- overflow-x: auto;
253
- background: var(--surface);
254
- border: var(--border) solid var(--secondary);
255
- box-shadow: 8px 8px 0 var(--secondary);
256
  }
257
 
258
- table {
 
259
  width: 100%;
260
- border-collapse: collapse;
 
 
 
 
 
 
261
  }
262
 
263
- thead {
264
- background: linear-gradient(135deg, var(--primary), var(--accent));
 
 
 
 
 
 
 
265
  }
266
 
267
- th {
268
- padding: 1.5rem 1rem;
269
- text-align: left;
270
- font-weight: 900;
271
- text-transform: uppercase;
272
- letter-spacing: 1px;
273
- color: var(--bg);
274
- border-right: 3px solid var(--bg);
275
  }
276
 
277
- th:last-child {
278
- border-right: none;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  }
280
 
281
- tbody tr {
282
- border-bottom: 2px solid rgba(0, 212, 255, 0.2);
283
- transition: all 0.3s ease;
284
- animation: fadeIn 0.5s ease;
285
  }
286
 
287
- @keyframes fadeIn {
288
- from {
289
- opacity: 0;
290
- }
291
-
292
- to {
293
- opacity: 1;
294
- }
 
295
  }
296
 
297
- tbody tr:hover {
298
- background: rgba(0, 255, 136, 0.1);
 
299
  }
300
 
301
- td {
302
- padding: 1.5rem 1rem;
303
- color: var(--text);
 
 
304
  }
305
 
306
- .status {
307
- display: inline-block;
308
- padding: 0.5rem 1rem;
309
- border: 3px solid;
310
- font-weight: 900;
311
- text-transform: uppercase;
312
- font-size: 0.85rem;
313
- letter-spacing: 1px;
 
314
  }
315
 
316
- .status-not_started {
317
- background: var(--bg);
318
- border-color: var(--accent);
319
- color: var(--accent);
 
 
 
 
 
 
 
 
320
  }
321
 
322
- .status-processing {
323
- background: var(--bg);
324
- border-color: var(--primary);
325
- color: var(--primary);
326
- animation: pulse 1.5s ease-in-out infinite;
 
 
327
  }
328
 
329
- @keyframes pulse {
330
-
331
- 0%,
332
- 100% {
333
- opacity: 1;
334
- }
335
-
336
- 50% {
337
- opacity: 0.6;
338
- }
339
  }
340
 
341
- .status-completed {
342
- background: var(--primary);
343
- border-color: var(--primary);
344
- color: var(--bg);
345
  }
346
 
347
- .status-failed {
348
- background: var(--error);
349
- border-color: var(--error);
350
- color: var(--text);
351
  }
352
 
353
- /* Progress display */
354
- .status-progress {
355
- display: flex;
356
- flex-direction: column;
357
- gap: 0.3rem;
358
- min-width: 120px;
359
  }
360
 
361
- .progress-bar-container {
362
- width: 100%;
363
- height: 6px;
364
- background: rgba(0, 212, 255, 0.2);
365
- border: 1px solid var(--accent);
366
- overflow: hidden;
367
  }
368
 
369
- .progress-bar {
 
 
 
 
 
 
 
 
 
 
370
  height: 100%;
371
- background: linear-gradient(90deg, var(--primary), var(--accent));
372
- transition: width 0.3s ease;
373
- animation: progressPulse 1.5s ease-in-out infinite;
374
- }
375
-
376
- @keyframes progressPulse {
377
-
378
- 0%,
379
- 100% {
380
- opacity: 1;
381
- }
382
-
383
- 50% {
384
- opacity: 0.7;
385
- }
386
- }
387
-
388
- .progress-text {
389
- font-size: 0.7rem;
390
- color: var(--accent);
391
- white-space: nowrap;
392
- overflow: hidden;
393
- text-overflow: ellipsis;
394
- }
395
-
396
- .text-cell {
397
- max-width: 300px;
398
- overflow: hidden;
399
- text-overflow: ellipsis;
400
- white-space: nowrap;
401
  }
402
 
403
- .empty-state {
404
- text-align: center;
405
- padding: 4rem 2rem;
406
- color: var(--accent);
407
- font-size: 1.2rem;
408
  }
409
 
410
- .refresh-btn {
411
- position: fixed;
412
- bottom: 2rem;
413
- right: 2rem;
414
- width: 60px;
415
- height: 60px;
416
- border-radius: 50%;
417
- background: var(--secondary);
418
- border: var(--border) solid var(--bg);
419
- box-shadow: 4px 4px 0 var(--bg);
 
 
420
  cursor: pointer;
421
- transition: all 0.3s ease;
422
- display: flex;
423
- align-items: center;
424
- justify-content: center;
425
  font-size: 1.5rem;
426
- z-index: 1000;
427
  }
428
 
429
- .refresh-btn:hover {
430
- transform: rotate(180deg) scale(1.1);
431
- box-shadow: 6px 6px 0 var(--bg);
 
432
  }
433
 
434
- /* Loader styles */
435
- .loader-overlay {
436
- position: fixed;
437
- top: 0;
438
- left: 0;
439
- width: 100%;
440
- height: 100%;
441
- background: rgba(10, 14, 39, 0.95);
442
  display: flex;
443
  align-items: center;
444
- justify-content: center;
445
- z-index: 9999;
446
- animation: fadeIn 0.3s ease;
447
- }
448
-
449
- .loader {
450
- width: 80px;
451
- height: 80px;
452
- border: 6px solid var(--surface);
453
- border-top: 6px solid var(--primary);
454
- border-right: 6px solid var(--accent);
455
- border-bottom: 6px solid var(--secondary);
456
- border-radius: 50%;
457
- animation: spin 1s linear infinite;
458
  }
459
 
460
- @keyframes spin {
461
- 0% {
462
- transform: rotate(0deg);
463
- }
464
-
465
- 100% {
466
- transform: rotate(360deg);
467
- }
468
  }
469
 
470
- .loader-text {
471
  position: absolute;
472
- margin-top: 120px;
473
- font-size: 1.2rem;
474
- font-weight: 900;
475
- color: var(--primary);
476
- text-transform: uppercase;
477
- letter-spacing: 2px;
478
- }
479
-
480
- @media (max-width: 768px) {
481
- .container {
482
- padding: 1rem;
483
- }
484
-
485
- .input-section,
486
- .table-wrapper {
487
- box-shadow: 4px 4px 0 var(--primary);
488
- }
489
-
490
- th,
491
- td {
492
- padding: 1rem 0.5rem;
493
- font-size: 0.9rem;
494
- }
495
-
496
- .text-cell {
497
- max-width: 150px;
498
- }
499
  }
500
 
501
- .notification {
502
- position: fixed;
503
- top: 2rem;
504
- right: 2rem;
505
- padding: 1.5rem 2rem;
506
- background: var(--primary);
507
- color: var(--bg);
508
- border: var(--border) solid var(--bg);
509
- box-shadow: 6px 6px 0 var(--bg);
510
- font-weight: 900;
511
- z-index: 2000;
512
- animation: slideInRight 0.5s ease, slideOutRight 0.5s ease 3.5s;
513
  }
514
 
515
- @keyframes slideInRight {
516
- from {
517
- transform: translateX(400px);
518
- opacity: 0;
519
- }
520
-
521
- to {
522
- transform: translateX(0);
523
- opacity: 1;
524
- }
525
  }
526
 
527
- @keyframes slideOutRight {
528
- to {
529
- transform: translateX(400px);
530
- opacity: 0;
531
- }
532
  }
533
 
534
- /* Text Popup Modal */
535
- .text-modal-overlay {
536
- position: fixed;
537
- top: 0;
538
- left: 0;
539
- width: 100%;
540
- height: 100%;
541
- background: rgba(10, 14, 39, 0.95);
542
  display: flex;
543
- align-items: center;
544
- justify-content: center;
545
- z-index: 9999;
546
- animation: fadeIn 0.3s ease;
547
- padding: 2rem;
548
  }
549
 
550
- .text-modal {
551
- background: var(--surface);
552
- border: var(--border) solid var(--accent);
553
- box-shadow: 8px 8px 0 var(--accent);
554
- max-width: 800px;
555
- width: 100%;
556
- max-height: 80vh;
557
- display: flex;
558
- flex-direction: column;
559
- animation: slideUp 0.3s ease;
560
  }
561
 
562
- .text-modal-header {
563
- display: flex;
564
- justify-content: space-between;
565
- align-items: center;
566
- padding: 1rem 1.5rem;
567
- background: linear-gradient(135deg, var(--primary), var(--accent));
568
- border-bottom: 3px solid var(--bg);
569
  }
570
 
571
- .text-modal-header h3 {
572
- color: var(--bg);
573
- font-weight: 900;
574
- text-transform: uppercase;
575
- letter-spacing: 1px;
576
  }
577
 
578
- .text-modal-close {
579
- background: var(--bg);
580
- border: 2px solid var(--bg);
581
- color: var(--primary);
582
- width: 36px;
583
- height: 36px;
584
- font-size: 1.2rem;
585
- cursor: pointer;
586
- display: flex;
587
- align-items: center;
588
- justify-content: center;
589
- transition: all 0.2s ease;
590
  }
591
 
592
- .text-modal-close:hover {
593
- background: var(--error);
594
- color: var(--text);
595
  }
596
 
597
- .text-modal-content {
598
- padding: 1.5rem;
599
- overflow-y: auto;
600
- flex: 1;
 
601
  }
 
 
602
 
603
- .text-modal-content pre {
604
- white-space: pre-wrap;
605
- word-wrap: break-word;
606
- font-family: inherit;
607
- font-size: 1rem;
608
- line-height: 1.6;
609
- color: var(--text);
610
- margin: 0;
611
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
612
 
613
- .text-modal-footer {
614
- padding: 1rem 1.5rem;
615
- border-top: 2px solid rgba(0, 212, 255, 0.2);
616
- display: flex;
617
- gap: 1rem;
618
- justify-content: flex-end;
619
- }
 
 
 
 
 
 
620
 
621
- .btn-view-text {
622
- background: var(--accent);
623
- padding: 0.4rem 0.8rem;
624
- font-size: 0.8rem;
625
- box-shadow: 2px 2px 0 var(--bg);
626
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627
 
628
- .btn-view-text:hover {
629
- transform: translate(-1px, -1px);
630
- box-shadow: 3px 3px 0 var(--bg);
631
- }
632
 
633
- .text-cell-wrapper {
634
- display: flex;
635
- align-items: center;
636
- gap: 0.5rem;
637
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
638
 
639
- .text-cell-wrapper .text-preview {
640
- max-width: 200px;
641
- overflow: hidden;
642
- text-overflow: ellipsis;
643
- white-space: nowrap;
644
- }
645
- </style>
646
- </head>
647
 
648
- <body>
649
- <div class="container">
650
- <header>
651
- <h1>TTS Generator</h1>
652
- <p class="subtitle">Text • Process • Audio</p>
653
- </header>
654
-
655
- <div class="input-section">
656
- <h2 style="margin-bottom: 1.5rem; color: var(--primary);">Generate Audio</h2>
657
-
658
- <textarea id="textInput" placeholder="Enter text to convert to speech..."></textarea>
659
-
660
- <div class="controls">
661
- <div class="control-group">
662
- <label>Voice</label>
663
- <select id="voiceSelect">
664
- <!-- Chatterbox Voice References (indices match base.py) -->
665
- <optgroup label="Female Voices">
666
- <option value="1">Main</option>
667
- <option value="2">Ellen</option>
668
- <option value="5">Ellen (Young)</option>
669
- <option value="9">English Woman</option>
670
- <option value="11">Kelly - Storytelling</option>
671
- <option value="14">Female News Reader</option>
672
- </optgroup>
673
-
674
- <optgroup label="Male Voices">
675
- <option value="3">Kratos</option>
676
- <option value="4" selected>American Male</option>
677
- <option value="6">Simple Guy</option>
678
- <option value="8">BBC News</option>
679
- <option value="10">David Castlemore - Newsreader</option>
680
- <option value="12">Motivational Coach</option>
681
- <option value="13">Sevan Bomar - Motivational</option>
682
- </optgroup>
683
- </select>
684
  </div>
685
- <div class="control-group">
686
- <label>Speed</label>
687
- <input type="number" id="speedInput" value="1.0" step="0.1" min="0.5" max="2.0">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
688
  </div>
 
 
 
 
 
689
  </div>
690
 
691
- <div style="display: flex; gap: 1rem; flex-wrap: wrap;">
692
- <button class="btn" id="generateBtn" style="flex: 1; min-width: 200px;">
693
- 🚀 Generate Audio
694
- </button>
695
- <button class="btn btn-secondary" id="uploadBtn" style="flex: 1; min-width: 200px;">
696
- 📁 Upload File
697
- </button>
698
- <input type="file" id="fileInput" accept=".txt,.md,.text" style="display: none;">
699
  </div>
700
- </div>
701
 
702
- <div class="table-section">
703
- <h2 style="margin-bottom: 1.5rem; color: var(--secondary);">Processing Queue</h2>
704
- <div class="table-wrapper">
705
- <table>
706
- <thead>
707
- <tr>
708
- <th>Text</th>
709
- <th>Status</th>
710
- <th>Audio</th>
711
- <th>Created</th>
712
- <th>Processed</th>
713
- </tr>
714
- </thead>
715
- <tbody id="filesTable">
716
- <tr>
717
- <td colspan="5" class="empty-state">No tasks yet. Start by generating audio!
718
- </td>
719
- </tr>
720
- </tbody>
721
- </table>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
722
  </div>
723
  </div>
724
  </div>
725
 
726
- <button class="refresh-btn" id="refreshBtn" title="Refresh">🔄</button>
727
-
728
- <!-- Loader -->
729
- <div class="loader-overlay" id="loader" style="display: none;">
730
- <div>
731
- <div class="loader"></div>
732
- <div class="loader-text">Queuing...</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
733
  </div>
734
  </div>
735
 
 
736
  <script>
737
- const API_URL = '/api';
738
-
739
- // Elements
740
- const textInput = document.getElementById('textInput');
741
- const voiceSelect = document.getElementById('voiceSelect');
742
- const speedInput = document.getElementById('speedInput');
743
- const generateBtn = document.getElementById('generateBtn');
744
- const uploadBtn = document.getElementById('uploadBtn');
745
- const fileInput = document.getElementById('fileInput');
746
- const loader = document.getElementById('loader');
747
- const refreshBtn = document.getElementById('refreshBtn');
748
-
749
- // Generate button
750
- generateBtn.addEventListener('click', async () => {
751
- const text = textInput.value.trim();
752
- if (!text) {
753
- showNotification('Please enter some text!', 'error');
754
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
755
  }
 
756
 
757
- const voice = voiceSelect.value;
758
- const speed = parseFloat(speedInput.value);
 
 
759
 
760
- // Show loader
761
- loader.style.display = 'flex';
762
- generateBtn.disabled = true;
 
 
 
 
 
763
 
 
 
764
  try {
765
- const response = await fetch(`${API_URL}/generate`, {
766
- method: 'POST',
767
- headers: {
768
- 'Content-Type': 'application/json'
769
- },
770
- body: JSON.stringify({
771
- text,
772
- voice,
773
- speed
774
- })
775
- });
776
-
777
- const data = await response.json();
778
-
779
- if (response.ok) {
780
- showNotification('Task queued successfully! 🎉');
781
- textInput.value = '';
782
- loadFiles();
783
- } else {
784
- showNotification(data.error || 'Generation failed', 'error');
785
- }
786
- } catch (error) {
787
- showNotification('Network error: ' + error.message, 'error');
788
- } finally {
789
- // Hide loader
790
- loader.style.display = 'none';
791
- generateBtn.disabled = false;
792
  }
793
- });
794
 
795
- // Upload button - opens file picker
796
- uploadBtn.addEventListener('click', () => {
797
- fileInput.click();
798
- });
799
 
800
- // File input change - fill textarea with file content
801
- fileInput.addEventListener('change', (e) => {
802
- const file = e.target.files[0];
803
- if (!file) return;
804
 
805
- // Read file content
806
- const reader = new FileReader();
807
- reader.onload = (event) => {
808
- const text = event.target.result.trim();
809
 
810
- if (!text) {
811
- showNotification('File is empty!', 'error');
812
- fileInput.value = '';
813
- return;
 
 
 
 
 
 
 
 
814
  }
 
 
 
 
 
 
815
 
816
- // Fill textarea with file content
817
- textInput.value = text;
818
- showNotification(`Loaded "${file.name}" - Ready to generate! 📄`);
819
- fileInput.value = ''; // Reset for next upload
820
- };
821
 
822
- reader.onerror = () => {
823
- showNotification('Error reading file!', 'error');
824
- fileInput.value = '';
825
- };
826
 
827
- reader.readAsText(file);
828
- });
 
 
829
 
830
- // Load files
831
- async function loadFiles() {
832
  try {
833
- const response = await fetch(`${API_URL}/files`);
834
- const files = await response.json();
835
-
836
- // Store files data for popup access
837
- filesData = files;
838
-
839
- const tbody = document.getElementById('filesTable');
840
-
841
- if (files.length === 0) {
842
- tbody.innerHTML = '<tr><td colspan="5" class="empty-state">No tasks yet. Start by generating audio!</td></tr>';
843
- return;
844
  }
845
-
846
- tbody.innerHTML = files.map((file, index) => {
847
- // Escape text for HTML attribute and display
848
- const escapedText = escapeHtml(file.text);
849
- const truncatedText = file.text.length > 50 ? file.text.substring(0, 50) + '...' : file.text;
850
-
851
- // Build status display with progress
852
- let statusDisplay = '';
853
- if (file.status === 'processing') {
854
- const progressPercent = file.progress || 0;
855
- const progressText = file.progress_text || 'Processing...';
856
- statusDisplay = `
857
- <div class="status-progress">
858
- <span class="status status-${file.status}">${progressPercent}%</span>
859
- <div class="progress-bar-container">
860
- <div class="progress-bar" style="width: ${progressPercent}%"></div>
861
- </div>
862
- <span class="progress-text">${escapeHtml(progressText)}</span>
863
- </div>
864
- `;
865
- } else {
866
- statusDisplay = `<span class="status status-${file.status}">${file.status.replace('_', ' ')}</span>`;
867
- }
868
-
869
- return `
870
- <tr>
871
- <td>
872
- <div class="text-cell-wrapper">
873
- <span class="text-preview" title="${escapedText}">${escapeHtml(truncatedText)}</span>
874
- <button class="btn btn-small btn-view-text" onclick="showTextPopup(${index})">👁️ View</button>
875
- </div>
876
- </td>
877
- <td>${statusDisplay}</td>
878
- <td>
879
- ${file.status === 'completed' && file.output_file ?
880
- `<a href="${API_URL}/download/${file.id}" class="btn btn-small btn-secondary" target="_blank">⬇️</a>`
881
- : '—'}
882
- </td>
883
- <td>${new Date(file.created_at).toLocaleString()}</td>
884
- <td>${file.processed_at ? new Date(file.processed_at).toLocaleString() : '—'}</td>
885
- </tr>
886
- `;
887
- }).join('');
888
- } catch (error) {
889
- console.error('Error loading files:', error);
890
  }
891
  }
892
 
893
- // Refresh button
894
- refreshBtn.addEventListener('click', () => {
895
- loadFiles();
896
- const icon = refreshBtn.textContent;
897
- refreshBtn.textContent = '⏳';
898
- setTimeout(() => refreshBtn.textContent = icon, 500);
899
- });
900
 
901
- // Auto-refresh every 10 minutes
902
- setInterval(loadFiles, 1000 * 60 * 10);
903
 
904
- // Initial load
905
- loadFiles();
906
 
907
- // Notification system
908
- function showNotification(message, type = 'success') {
909
- const notification = document.createElement('div');
910
- notification.className = 'notification';
911
- if (type === 'error') {
912
- notification.style.background = 'var(--error)';
913
- notification.style.borderColor = 'var(--error)';
914
- }
915
- notification.textContent = message;
916
- document.body.appendChild(notification);
917
 
918
- setTimeout(() => {
919
- notification.remove();
920
- }, 4000);
 
921
  }
922
 
923
- // Store files data for popup access
924
- let filesData = [];
925
-
926
- // Escape HTML to prevent XSS
927
- function escapeHtml(text) {
928
- const div = document.createElement('div');
929
- div.textContent = text;
930
- return div.innerHTML;
 
 
 
 
 
 
931
  }
932
 
933
- // Show text popup
934
- function showTextPopup(index) {
935
- const file = filesData[index];
936
- if (!file) return;
937
-
938
- // Create modal overlay
939
- const overlay = document.createElement('div');
940
- overlay.className = 'text-modal-overlay';
941
- overlay.onclick = (e) => {
942
- if (e.target === overlay) overlay.remove();
943
- };
944
 
945
- // Create modal content
946
- overlay.innerHTML = `
947
- <div class="text-modal">
948
- <div class="text-modal-header">
949
- <h3>📝 Full Text</h3>
950
- <button class="text-modal-close" onclick="this.closest('.text-modal-overlay').remove()">✕</button>
951
- </div>
952
- <div class="text-modal-content">
953
- <pre>${escapeHtml(file.text)}</pre>
954
- </div>
955
- <div class="text-modal-footer">
956
- <button class="btn btn-small btn-secondary" onclick="copyTextToClipboard(${index})">📋 Copy Text</button>
957
- <button class="btn btn-small" onclick="this.closest('.text-modal-overlay').remove()">Close</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
958
  </div>
959
- </div>
960
- `;
961
-
962
- document.body.appendChild(overlay);
963
-
964
- // Close on Escape key
965
- const handleEscape = (e) => {
966
- if (e.key === 'Escape') {
967
- overlay.remove();
968
- document.removeEventListener('keydown', handleEscape);
969
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
970
  };
971
- document.addEventListener('keydown', handleEscape);
972
- }
973
-
974
- // Copy text to clipboard
975
- function copyTextToClipboard(index) {
976
- const file = filesData[index];
977
- if (!file) return;
978
-
979
- navigator.clipboard.writeText(file.text).then(() => {
980
- showNotification('Text copied to clipboard! 📋');
981
- }).catch((err) => {
982
- console.error('Failed to copy:', err);
983
- showNotification('Failed to copy text', 'error');
984
- });
985
- }
986
  </script>
987
  </body>
988
 
 
2
  <html lang="en">
3
 
4
  <head>
5
+ <meta charset="utf-8" />
6
+ <meta content="width=device-width, initial-scale=1.0" name="viewport" />
7
+ <title>TTS - Text-to-Speech</title>
8
+
9
+ <!-- External Assets -->
10
+ <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
11
+ <link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@300..700&family=Caveat:wght@400..700&display=swap"
12
+ rel="stylesheet" />
13
+ <link
14
+ href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap"
15
+ rel="stylesheet" />
16
+
17
+ <!-- Tailwind Configuration -->
18
+ <script id="tailwind-config">
19
+ tailwind.config = {
20
+ darkMode: "class",
21
+ theme: {
22
+ extend: {
23
+ colors: {
24
+ surface: "#e6f0fd",
25
+ "crayon-blue": "#2563eb",
26
+ "crayon-red": "#dc2626",
27
+ "crayon-green": "#16a34a",
28
+ "crayon-yellow": "#ca8a04",
29
+ "crayon-orange": "#ea580c",
30
+ "crayon-purple": "#7c3aed",
31
+ "crayon-dark": "#1A1A1A"
32
+ },
33
+ fontFamily: {
34
+ "fredoka": ["Fredoka", "sans-serif"],
35
+ "caveat": ["Caveat", "cursive"]
36
+ },
37
+ fontSize: {
38
+ "headline-lg": ["42px", { lineHeight: "1.1", fontWeight: "700" }],
39
+ "headline-md": ["28px", { lineHeight: "1.2", fontWeight: "600" }],
40
+ "body-lg": ["24px", { lineHeight: "1.5", fontWeight: "500" }],
41
+ "label-sm": ["18px", { lineHeight: "1.2", letterSpacing: "0.01em", fontWeight: "500" }],
42
+ "body-md": ["20px", { lineHeight: "1.5", fontWeight: "400" }]
43
+ }
44
+ },
45
+ },
46
  }
47
+ </script>
48
 
49
+ <svg height="0" style="position: absolute;" width="0">
50
+ <filter height="120%" id="crayon-texture" width="120%" x="-10%" y="-10%">
51
+ <feTurbulence baseFrequency="0.4" numOctaves="3" result="noise" type="fractalNoise"></feTurbulence>
52
+ <feDisplacementMap in="SourceGraphic" in2="noise" scale="2.5" xChannelSelector="R" yChannelSelector="G">
53
+ </feDisplacementMap>
54
+ </filter>
55
+ <filter height="120%" id="crayon-heavy" width="120%" x="-10%" y="-10%">
56
+ <feTurbulence baseFrequency="0.5" numOctaves="4" result="noise" type="fractalNoise"></feTurbulence>
57
+ <feDisplacementMap in="SourceGraphic" in2="noise" scale="4" xChannelSelector="R" yChannelSelector="G">
58
+ </feDisplacementMap>
59
+ </filter>
60
+ </svg>
61
 
62
+ <style>
63
+ /* Sketchbook Styles */
64
  body {
65
+ background-color: #e6f0fd;
66
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.08'/%3E%3C/svg%3E");
67
+ color: #1A1A1A;
68
+ font-family: 'Fredoka', sans-serif;
 
 
69
  }
70
 
71
+ .bg-surface {
72
+ background-color: rgb(255 255 255 / 0%) !important;
73
+ backdrop-filter: blur(2px);
 
 
 
 
 
 
 
 
 
 
74
  }
75
 
76
+ .crayon-filter {
77
+ filter: url('#crayon-texture');
 
 
 
 
78
  }
79
 
80
+ .crayon-heavy {
81
+ filter: url('#crayon-heavy');
 
 
82
  }
83
 
84
+ .crayon-border-green {
85
+ border: 4px solid #16a34a;
86
+ border-radius: 12px 8px 15px 10px / 8px 14px 10px 12px;
87
+ filter: url('#crayon-texture');
 
 
 
 
 
 
88
  }
89
 
90
+ .task-card {
91
+ border: 3px solid rgba(124, 58, 237, 0.4);
92
+ border-radius: 20px 15px 25px 18px / 18px 25px 15px 20px;
93
+ filter: url('#crayon-texture');
 
 
 
 
 
 
 
 
94
  }
95
 
96
+ .crayon-border-blue {
97
+ border: 4px dashed #2563eb;
98
+ border-radius: 15px 10px 12px 18px / 12px 18px 15px 10px;
99
+ filter: url('#crayon-texture');
 
 
 
 
 
 
100
  }
101
 
102
+ .crayon-border-purple {
103
+ border: 4px solid #7c3aed;
104
+ border-radius: 10px 16px 12px 14px / 16px 12px 14px 10px;
105
+ filter: url('#crayon-texture');
106
+ }
 
107
 
108
+ .crayon-button {
109
+ border: 4px solid #2563eb;
110
+ border-radius: 12px 8px 14px 10px / 8px 14px 10px 12px;
111
+ transition: all 0.2s ease;
112
+ filter: url('#crayon-texture');
113
+ cursor: pointer;
114
  }
115
 
116
+ .crayon-button:hover {
117
+ transform: scale(1.05) rotate(1deg);
118
+ box-shadow: 6px 6px 0px 0px rgba(0, 0, 0, 0.1);
 
119
  }
120
 
121
+ .crayon-button:hover .material-symbols-outlined.spin-on-hover {
122
+ animation: spin 2s linear infinite;
 
 
 
 
 
 
 
123
  }
124
 
125
+ @keyframes spin {
126
  from {
127
+ transform: rotate(0deg);
 
128
  }
129
 
130
  to {
131
+ transform: rotate(360deg);
 
132
  }
133
  }
134
 
135
+ @keyframes drift {
136
+ 0% {
137
+ transform: translateX(0);
138
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
+ 50% {
141
+ transform: translateX(20px);
142
+ }
 
 
 
143
 
144
+ 100% {
145
+ transform: translateX(0);
146
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  }
148
 
149
+ .drift-slow {
150
+ animation: drift 8s ease-in-out infinite;
 
151
  }
152
 
153
+ .drift-medium {
154
+ animation: drift 5s ease-in-out infinite;
 
155
  }
156
 
157
+ .organic-shape {
158
+ border-radius: 255px 15px 225px 15px/15px 225px 15px 255px;
159
+ filter: url('#crayon-texture');
160
  }
161
 
162
+ .scribble-fill-green {
163
+ background: repeating-linear-gradient(60deg, #16a34a, #16a34a 2px, #15803d 3px, #16a34a 4px);
164
  }
165
 
166
+ .progress-fill {
167
+ background: repeating-linear-gradient(80deg, #2563eb, #2563eb 2px, #1d4ed8 3px, #2563eb 5px);
 
 
 
 
 
168
  }
169
 
170
+ .material-symbols-outlined {
171
+ font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
172
+ filter: url('#crayon-texture');
173
  }
174
 
175
+ /* --- Modals --- */
176
+ .modal {
177
+ position: fixed;
178
+ inset: 0;
179
+ background: rgba(15, 23, 42, 0.6);
180
+ display: none;
181
+ align-items: center;
182
+ justify-content: center;
183
+ z-index: 100;
184
+ padding: 2rem;
185
  }
186
 
187
+ .modal.active {
188
+ display: flex;
 
 
 
189
  }
190
 
191
+ .modal-content {
192
+ border: 4px solid #2563eb;
193
  width: 100%;
194
+ max-width: 900px;
195
+ border-radius: 24px;
196
+ display: flex;
197
+ flex-direction: column;
198
+ max-height: 85vh;
199
+ box-shadow: 12px 12px 0px 0px rgba(0, 0, 0, 0.1);
200
+ position: relative;
201
  }
202
 
203
+ .modal-sketch-bg {
204
+ position: absolute;
205
+ inset: -8px;
206
+ border: 6px solid #2563eb;
207
+ backdrop-filter: blur(10px);
208
+ border-radius: 255px 15px 225px 15px/15px 225px 15px 255px;
209
+ z-index: -1;
210
+ filter: url('#crayon-texture');
211
+ pointer-events: none;
212
  }
213
 
214
+ .modal-header {
215
+ padding: 1.5rem 2rem;
216
+ border-bottom: 3px dashed #adc6ff;
217
+ display: flex;
218
+ justify-content: space-between;
219
+ align-items: center;
220
+ position: relative;
221
+ z-index: 10;
222
  }
223
 
224
+ .modal-body {
225
+ padding: 2rem;
226
+ overflow-y: auto;
227
+ position: relative;
228
+ z-index: 10;
229
+ }
230
+
231
+ #resultText,
232
+ pre {
233
+ border: 3px dashed #adc6ff !important;
234
+ padding: 2rem !important;
235
+ border-radius: 20px !important;
236
+ font-family: 'Fredoka', sans-serif !important;
237
+ font-size: 1.5rem !important;
238
+ font-weight: 600 !important;
239
+ color: #1A1A1A !important;
240
+ white-space: pre-wrap !important;
241
+ word-break: break-all !important;
242
+ line-height: 1.6 !important;
243
+ filter: url('#crayon-texture');
244
+ }
245
+
246
+ .close-modal {
247
+ background: transparent;
248
+ border: none;
249
+ color: #dc2626;
250
+ font-size: 3rem;
251
+ cursor: pointer;
252
+ font-weight: 700;
253
  }
254
 
255
+ .text-headline-lg {
256
+ filter: url('#crayon-texture');
 
 
257
  }
258
 
259
+ .copy-btn {
260
+ background: #16a34a;
261
+ color: white;
262
+ padding: 0.5rem 1.5rem;
263
+ border-radius: 12px;
264
+ font-weight: 700;
265
+ box-shadow: 4px 4px 0px 0px #15803d;
266
+ transition: all 0.2s;
267
+ filter: url('#crayon-texture');
268
  }
269
 
270
+ .copy-btn:hover {
271
+ transform: translate(-2px, -2px);
272
+ box-shadow: 6px 6px 0px 0px #15803d;
273
  }
274
 
275
+ /* --- Specific UI Elements --- */
276
+ .status-modal-content {
277
+ max-width: 450px;
278
+ text-align: center;
279
+ padding: 3rem 2rem;
280
  }
281
 
282
+ .status-modal-bg {
283
+ position: absolute;
284
+ inset: -12px;
285
+ border: 8px solid #2563eb;
286
+ background: #e6f0fd;
287
+ border-radius: 20px 40px 15px 35px / 35px 15px 40px 20px;
288
+ z-index: -1;
289
+ filter: url('#crayon-texture');
290
+ pointer-events: none;
291
  }
292
 
293
+ .status-icon-container {
294
+ width: 100px;
295
+ height: 100px;
296
+ margin: 0 auto 1.5rem;
297
+ display: flex;
298
+ align-items: center;
299
+ justify-content: center;
300
+ border-radius: 20px 15px 25px 18px / 18px 25px 15px 20px;
301
+ border: 4px solid currentColor;
302
+ filter: url('#crayon-texture');
303
+ position: relative;
304
+ z-index: 20;
305
  }
306
 
307
+ .status-icon-bg {
308
+ position: absolute;
309
+ inset: 0;
310
+ background: currentColor;
311
+ opacity: 0.15;
312
+ z-index: -1;
313
+ border-radius: inherit;
314
  }
315
 
316
+ .modal-decoration {
317
+ position: absolute;
318
+ pointer-events: none;
319
+ opacity: 0.3;
320
+ z-index: 5;
321
+ filter: url('#crayon-texture');
 
 
 
 
322
  }
323
 
324
+ .table-container::-webkit-scrollbar {
325
+ width: 10px;
 
 
326
  }
327
 
328
+ .table-container::-webkit-scrollbar-track {
329
+ background: #f1f5f9;
330
+ border-radius: 10px;
 
331
  }
332
 
333
+ .table-container::-webkit-scrollbar-thumb {
334
+ background: #cbd5e1;
335
+ border-radius: 10px;
336
+ border: 2px solid #f1f5f9;
 
 
337
  }
338
 
339
+ .dragging {
340
+ border-color: #16a34a !important;
341
+ background-color: #f0fdf4 !important;
 
 
 
342
  }
343
 
344
+ textarea {
345
+ background: rgba(255, 255, 255, 0.4);
346
+ border: 4px solid #2563eb;
347
+ border-radius: 15px 10px 12px 18px / 12px 18px 15px 10px;
348
+ outline: none;
349
+ resize: none;
350
+ font-family: 'Fredoka', sans-serif;
351
+ font-size: 1.5rem;
352
+ font-weight: 600;
353
+ color: #1A1A1A;
354
+ width: 100%;
355
  height: 100%;
356
+ min-height: 100px;
357
+ padding: 1rem;
358
+ filter: url('#crayon-texture');
359
+ transition: border-color 0.2s;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  }
361
 
362
+ textarea:focus {
363
+ border-color: #7c3aed;
364
+ background: #fff;
 
 
365
  }
366
 
367
+ select,
368
+ input[type="number"] {
369
+ background: rgba(255, 255, 255, 0.4);
370
+ border: 4px solid #7c3aed;
371
+ border-radius: 12px 8px 15px 10px / 8px 14px 10px 12px;
372
+ padding: 0.5rem 0.8rem;
373
+ font-family: 'Fredoka', sans-serif;
374
+ color: #1e1b4b;
375
+ filter: url('#crayon-texture');
376
+ outline: none;
377
+ appearance: none;
378
+ transition: all 0.2s;
379
  cursor: pointer;
 
 
 
 
380
  font-size: 1.5rem;
381
+ font-weight: 600;
382
  }
383
 
384
+ /* --- Custom Dropdown --- */
385
+ .custom-dropdown {
386
+ position: relative;
387
+ width: 100%;
388
  }
389
 
390
+ .dropdown-trigger {
 
 
 
 
 
 
 
391
  display: flex;
392
  align-items: center;
393
+ justify-content: space-between;
394
+ background: rgba(255, 255, 255, 0.4);
395
+ border: 4px solid #7c3aed;
396
+ border-radius: 12px 8px 15px 10px / 8px 14px 10px 12px;
397
+ padding: 0.5rem 0.8rem;
398
+ font-family: 'Fredoka', sans-serif;
399
+ font-size: 1.5rem;
400
+ font-weight: 600;
401
+ color: #1e1b4b;
402
+ filter: url('#crayon-texture');
403
+ cursor: pointer;
404
+ transition: all 0.2s;
 
 
405
  }
406
 
407
+ .dropdown-trigger:hover {
408
+ transform: scale(1.02) rotate(-0.5deg);
409
+ border-color: #2563eb;
 
 
 
 
 
410
  }
411
 
412
+ .dropdown-popup {
413
  position: absolute;
414
+ top: calc(100% + 8px);
415
+ left: 0;
416
+ width: 100%;
417
+ background: #fff;
418
+ border: 4px solid #7c3aed;
419
+ border-radius: 15px 25px 18px 22px / 22px 18px 25px 15px;
420
+ z-index: 150;
421
+ display: none;
422
+ flex-direction: column;
423
+ overflow-y: auto;
424
+ max-height: 200px;
425
+ box-shadow: 8px 8px 0px 0px rgba(0, 0, 0, 0.1);
426
+ filter: url('#crayon-texture');
427
+ font-size: 1.5rem;
428
+ font-weight: 600;
 
 
 
 
 
 
 
 
 
 
 
 
429
  }
430
 
431
+ .dropdown-popup::-webkit-scrollbar {
432
+ width: 8px;
 
 
 
 
 
 
 
 
 
 
433
  }
434
 
435
+ .dropdown-popup::-webkit-scrollbar-track {
436
+ background: #f1f5f9;
 
 
 
 
 
 
 
 
437
  }
438
 
439
+ .dropdown-popup::-webkit-scrollbar-thumb {
440
+ background: #adc6ff;
441
+ border-radius: 10px;
 
 
442
  }
443
 
444
+ .dropdown-popup.active {
 
 
 
 
 
 
 
445
  display: flex;
 
 
 
 
 
446
  }
447
 
448
+ .dropdown-group {
449
+ padding: 0.3rem 0.8rem;
450
+ background: #e6f0fd;
451
+ color: #7c3aed;
452
+ border-bottom: 2px dashed #adc6ff;
 
 
 
 
 
453
  }
454
 
455
+ .dropdown-option {
456
+ padding: 0.8rem 1.2rem;
457
+ cursor: pointer;
458
+ transition: all 0.2s;
459
+ font-weight: 600;
460
+ border-bottom: 1px solid #f1f5f9;
 
461
  }
462
 
463
+ .dropdown-option:last-child {
464
+ border-bottom: none;
 
 
 
465
  }
466
 
467
+ .dropdown-option:hover {
468
+ background: #f5f3ff;
469
+ color: #7c3aed;
470
+ padding-left: 1.5rem;
 
 
 
 
 
 
 
 
471
  }
472
 
473
+ .dropdown-option.selected {
474
+ background: #e6f0fd;
475
+ color: #2563eb;
476
  }
477
 
478
+ audio {
479
+ width: 100%;
480
+ height: 50px;
481
+ border-radius: 12px;
482
+ filter: url('#crayon-texture');
483
  }
484
+ </style>
485
+ </head>
486
 
487
+ <body class="h-screen flex flex-col overflow-hidden relative">
488
+ <!-- Main Background Decorations -->
489
+ <div class="fixed inset-0 pointer-events-none overflow-hidden -z-10 opacity-40">
490
+ <!-- Stars -->
491
+ <span
492
+ class="material-symbols-outlined absolute text-5xl text-crayon-yellow top-20 left-[10%] rotate-12 crayon-heavy animate-pulse">star</span>
493
+ <span
494
+ class="material-symbols-outlined absolute text-3xl text-crayon-orange top-[40%] left-[5%] -rotate-12 crayon-filter">star</span>
495
+ <span
496
+ class="material-symbols-outlined absolute text-4xl text-crayon-yellow bottom-[20%] left-[15%] rotate-45 animate-pulse">star</span>
497
+ <span
498
+ class="material-symbols-outlined absolute text-6xl text-crayon-orange top-[15%] right-[15%] rotate-[-15deg] crayon-heavy">star</span>
499
+ <span
500
+ class="material-symbols-outlined absolute text-3xl text-crayon-yellow bottom-[30%] right-[10%] rotate-12 animate-pulse">star</span>
501
+
502
+ <!-- Clouds -->
503
+ <span
504
+ class="material-symbols-outlined absolute text-[120px] text-crayon-blue top-[10%] left-[25%] opacity-20 drift-slow">cloud</span>
505
+ <span
506
+ class="material-symbols-outlined absolute text-[80px] text-crayon-purple bottom-[15%] left-[40%] opacity-10 drift-medium">cloud</span>
507
+ <span
508
+ class="material-symbols-outlined absolute text-[100px] text-crayon-blue top-[60%] right-[25%] opacity-15 drift-slow">cloud</span>
509
+ <span
510
+ class="material-symbols-outlined absolute text-[150px] text-crayon-purple top-[30%] right-[5%] opacity-10 drift-medium">cloud</span>
511
+
512
+ <!-- Hearts -->
513
+ <span
514
+ class="material-symbols-outlined absolute text-4xl text-crayon-red top-[25%] left-[18%] rotate-[-15deg] crayon-filter animate-pulse">favorite</span>
515
+ <span
516
+ class="material-symbols-outlined absolute text-2xl text-crayon-red bottom-[10%] right-[20%] rotate-12 crayon-filter">favorite</span>
517
+ <span
518
+ class="material-symbols-outlined absolute text-5xl text-crayon-red top-[70%] left-[8%] rotate-[10deg] animate-pulse">favorite</span>
519
+ </div>
520
 
521
+ <!-- Header -->
522
+ <header
523
+ class="bg-surface flex justify-between items-center w-[calc(100%-48px)] mx-6 mt-6 px-8 py-5 crayon-border-green z-10 shrink-0 organic-shape shadow-sm">
524
+ <div class="flex items-center gap-5">
525
+ <div
526
+ class="bg-crayon-green text-white w-14 h-14 rounded-2xl flex items-center justify-center border-[3px] border-crayon-green rotate-[-4deg] crayon-filter scribble-fill-green shadow-md">
527
+ <span class="material-symbols-outlined text-4xl">record_voice_over</span>
528
+ </div>
529
+ <div class="flex flex-col -rotate-1">
530
+ <h1 class="text-headline-lg text-[#4c1d95] leading-none mb-1">TTS</h1>
531
+ <span class="text-label-sm text-[#4b5563] font-bold">Text-to-Speech</span>
532
+ </div>
533
+ </div>
534
 
535
+ <div class="flex items-center gap-10 relative">
536
+ <div class="absolute -left-48 top-2 rotate-12 crayon-heavy">
537
+ <span class="material-symbols-outlined text-4xl text-crayon-yellow">star</span>
538
+ </div>
539
+ <div class="absolute -left-24 top-0 -rotate-6 crayon-heavy opacity-80">
540
+ <span class="material-symbols-outlined text-5xl text-crayon-blue">cloud</span>
541
+ </div>
542
+ <button id="apiDocBtn"
543
+ class="flex items-center gap-2 text-headline-md text-crayon-purple px-8 py-3 bg-surface crayon-button rotate-1 shadow-md">
544
+ <span class="material-symbols-outlined text-3xl">menu_book</span>
545
+ API DOC
546
+ </button>
547
+ <div
548
+ class="flex items-center gap-4 bg-surface px-6 py-3 rounded-full border-[4px] border-crayon-green organic-shape shadow-md">
549
+ <div id="healthDot" class="w-4 h-4 rounded-full bg-crayon-green shadow-[0_0_12px_rgba(22,163,74,0.5)]">
550
+ </div>
551
+ <span id="healthText" class="text-headline-md text-crayon-green text-2xl">Service Online</span>
552
+ <div class="text-crayon-orange flex items-center justify-center rotate-[15deg]">
553
+ <span class="material-symbols-outlined text-4xl">light_mode</span>
554
+ </div>
555
+ </div>
556
+ </div>
557
+ </header>
558
+
559
+ <!-- Main Content -->
560
+ <main class="flex-1 flex overflow-hidden p-8 gap-8 mx-auto w-full relative">
561
+ <!-- Input Section -->
562
+ <section class="w-[350px] flex flex-col gap-8">
563
+ <div
564
+ class="flex flex-col gap-4 p-6 bg-surface crayon-border-blue flex-1 relative organic-shape shadow-sm hover:shadow-md transition-shadow overflow-y-auto table-container">
565
+ <div class="flex items-center gap-4 mb-2 rotate-1">
566
+ <div
567
+ class="bg-crayon-blue text-white rounded-full w-12 h-12 flex items-center justify-center border-2 border-crayon-blue crayon-filter shadow-md">
568
+ <span class="material-symbols-outlined text-3xl">edit_note</span>
569
+ </div>
570
+ <div>
571
+ <h2 class="text-headline-md text-crayon-blue leading-none mb-1 flex items-center gap-2">
572
+ INPUT
573
+ <span class="material-symbols-outlined text-crayon-red text-xl rotate-12">favorite</span>
574
+ </h2>
575
+ <p class="text-label-sm text-[#6b7280]">Type or upload text</p>
576
+ </div>
577
+ </div>
578
 
579
+ <div class="flex-1 flex flex-col gap-4">
580
+ <div class="flex-1 min-h-[120px]">
581
+ <textarea id="textInput" placeholder="Enter text to speak..."></textarea>
582
+ </div>
583
 
584
+ <div class="flex flex-col gap-1">
585
+ <label class="text-label-sm font-bold text-crayon-purple">VOICE</label>
586
+ <div class="custom-dropdown" id="voiceDropdown">
587
+ <div class="dropdown-trigger" id="voiceTrigger">
588
+ <span id="selectedVoice">American Male</span>
589
+ <span class="material-symbols-outlined">expand_more</span>
590
+ </div>
591
+ <div class="dropdown-popup" id="voicePopup">
592
+ <div class="dropdown-group">Female</div>
593
+ <div class="dropdown-option" data-value="1">Main</div>
594
+ <div class="dropdown-option" data-value="2">Ellen</div>
595
+ <div class="dropdown-option" data-value="5">Ellen (Young)</div>
596
+ <div class="dropdown-option" data-value="9">British Woman</div>
597
+ <div class="dropdown-option" data-value="11">Kelly (Story)</div>
598
+ <div class="dropdown-option" data-value="14">News Female</div>
599
+ <div class="dropdown-group">Male</div>
600
+ <div class="dropdown-option" data-value="3">Kratos</div>
601
+ <div class="dropdown-option selected" data-value="4">American Male</div>
602
+ <div class="dropdown-option" data-value="6">Simple Guy</div>
603
+ <div class="dropdown-option" data-value="8">BBC News</div>
604
+ <div class="dropdown-option" data-value="10">David (News)</div>
605
+ <div class="dropdown-option" data-value="12">Coach</div>
606
+ <div class="dropdown-option" data-value="13">Motivational</div>
607
+ </div>
608
+ </div>
609
+ <input type="hidden" id="voiceSelect" value="4">
610
+ </div>
611
 
612
+ <div class="flex flex-col gap-1">
613
+ <label class="text-label-sm font-bold text-crayon-purple">SPEED</label>
614
+ <input type="number" id="speedInput" value="1.0" step="0.1" min="0.5" max="2.0">
615
+ </div>
 
 
 
 
616
 
617
+ <button id="generateBtn"
618
+ class="crayon-button bg-crayon-blue text-white py-3 text-headline-md mt-2 flex items-center justify-center gap-2">
619
+ <span class="material-symbols-outlined text-3xl">rocket_launch</span>
620
+ GENERATE
621
+ </button>
622
+
623
+ <div class="text-center py-2">
624
+ <span class="text-label-sm text-[#94a3b8] font-bold">OR</span>
625
+ <div id="uploadZone"
626
+ class="mt-2 py-3 border-[3px] border-dashed border-crayon-green rounded-xl cursor-pointer hover:bg-green-50 transition-colors">
627
+ <p class="text-label-sm text-crayon-green font-bold flex items-center justify-center gap-2">
628
+ <span class="material-symbols-outlined">upload_file</span>
629
+ Upload .txt
630
+ </p>
631
+ </div>
632
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
633
  </div>
634
+ <input type="file" id="fileInput" hidden accept=".txt,.md,.text">
635
+ </div>
636
+ </section>
637
+
638
+ <!-- Activity Section -->
639
+ <section
640
+ class="flex-1 flex flex-col bg-surface crayon-border-purple p-8 relative organic-shape overflow-hidden shadow-sm">
641
+ <div class="flex items-center justify-between mb-8">
642
+ <div class="flex items-center gap-4 rotate-1">
643
+ <div
644
+ class="bg-crayon-purple text-white rounded-full w-14 h-14 flex items-center justify-center border-[3px] border-crayon-purple shadow-md">
645
+ <span class="material-symbols-outlined text-4xl">schedule</span>
646
+ </div>
647
+ <div>
648
+ <h2 class="text-headline-lg text-[#4c1d95] leading-none mb-1">ACTIVITY</h2>
649
+ <p class="text-label-sm text-[#6b7280] font-bold">Recent speech generations</p>
650
+ </div>
651
  </div>
652
+ <button onclick="loadTasks()"
653
+ class="flex items-center gap-2 text-headline-md text-crayon-blue px-8 py-3 bg-surface crayon-button border-[4px] -rotate-1 shadow-md">
654
+ <span class="material-symbols-outlined text-3xl spin-on-hover">sync</span>
655
+ Refresh
656
+ </button>
657
  </div>
658
 
659
+ <!-- List Header -->
660
+ <div class="flex w-full px-6 pb-4 border-b-[5px] text-[#4c1d95] font-bold text-xl uppercase tracking-wider"
661
+ style="border-color: rgba(124, 58, 237, 0.4); border-radius: 20px 15px 25px 18px / 18px 25px 15px 20px; filter: url(#crayon-texture);">
662
+ <div class="w-1/2">CONTENT</div>
663
+ <div class="w-1/6 text-center">STATUS</div>
664
+ <div class="w-1/4 text-center">PROGRESS</div>
665
+ <div class="w-[10%] text-center">ACTION</div>
 
666
  </div>
 
667
 
668
+ <!-- Task List Body -->
669
+ <div id="queueBody" class="flex flex-col gap-5 mt-6 overflow-y-auto flex-1 table-container pr-4">
670
+ <div class="text-center py-32 text-headline-md text-[#94a3b8] font-bold opacity-60">No speech tasks
671
+ found yet...</div>
672
+ </div>
673
+ </section>
674
+ </main>
675
+
676
+ <!-- Modals -->
677
+ <!-- Result & API Modal -->
678
+ <div id="resultModal" class="modal">
679
+ <div class="modal-content">
680
+ <div class="modal-sketch-bg"></div>
681
+
682
+ <!-- Decorations -->
683
+ <span
684
+ class="material-symbols-outlined modal-decoration text-6xl text-crayon-yellow top-4 left-4 -rotate-12">star</span>
685
+ <span
686
+ class="material-symbols-outlined modal-decoration text-8xl text-crayon-blue top-12 right-20 opacity-20">cloud</span>
687
+ <span
688
+ class="material-symbols-outlined modal-decoration text-4xl text-crayon-orange bottom-10 left-10 rotate-45">star</span>
689
+ <span
690
+ class="material-symbols-outlined modal-decoration text-7xl text-crayon-purple bottom-4 right-4 -rotate-6 opacity-40">cloud</span>
691
+
692
+ <div class="modal-header">
693
+ <div class="flex items-center gap-6">
694
+ <span id="modalTitle" class="text-headline-lg text-[#1e1b4b]">Result</span>
695
+ <button id="copyBtn" onclick="copyResult()" class="copy-btn">📋 Copy</button>
696
+ <a id="downloadBtn" href="#" download class="copy-btn bg-crayon-blue shadow-blue-800">⬇️ Download
697
+ Audio</a>
698
+ </div>
699
+ <button class="close-modal" onclick="closeModal()">&times;</button>
700
+ </div>
701
+ <div class="modal-body">
702
+ <div id="audioContainer" class="mb-6 hidden">
703
+ <audio id="audioPlayer" controls></audio>
704
+ </div>
705
+ <pre id="resultText"></pre>
706
  </div>
707
  </div>
708
  </div>
709
 
710
+ <!-- Status Modal -->
711
+ <div id="statusModal" class="modal">
712
+ <div class="modal-content status-modal-content">
713
+ <div id="statusBg" class="status-modal-bg"></div>
714
+
715
+ <span
716
+ class="material-symbols-outlined modal-decoration text-4xl text-crayon-yellow top-6 right-6 rotate-12">star</span>
717
+ <span
718
+ class="material-symbols-outlined modal-decoration text-6xl text-crayon-blue top-10 left-4 opacity-30">cloud</span>
719
+ <span
720
+ class="material-symbols-outlined modal-decoration text-3xl text-crayon-orange bottom-8 right-10 -rotate-12">star</span>
721
+ <span
722
+ class="material-symbols-outlined modal-decoration text-5xl text-crayon-purple bottom-10 left-8 rotate-6 opacity-30">cloud</span>
723
+
724
+ <div id="statusIconContainer" class="status-icon-container text-crayon-blue">
725
+ <div class="status-icon-bg"></div>
726
+ <span id="statusIcon" class="material-symbols-outlined text-6xl animate-bounce">upload</span>
727
+ </div>
728
+ <h2 id="statusMessage" class="text-headline-lg text-crayon-blue mb-2">Processing...</h2>
729
+ <p id="statusSubMessage" class="text-body-lg text-[#4b5563]">Creating your audio task, please wait.</p>
730
  </div>
731
  </div>
732
 
733
+ <!-- Application Logic -->
734
  <script>
735
+ // --- Configuration ---
736
+ const API_BASE = '/api';
737
+
738
+ // --- DOM Elements ---
739
+ const UI = {
740
+ generateBtn: document.getElementById('generateBtn'),
741
+ uploadZone: document.getElementById('uploadZone'),
742
+ fileInput: document.getElementById('fileInput'),
743
+ textInput: document.getElementById('textInput'),
744
+ voiceSelect: document.getElementById('voiceSelect'),
745
+ voiceDropdown: document.getElementById('voiceDropdown'),
746
+ voiceTrigger: document.getElementById('voiceTrigger'),
747
+ voicePopup: document.getElementById('voicePopup'),
748
+ selectedVoice: document.getElementById('selectedVoice'),
749
+ speedInput: document.getElementById('speedInput'),
750
+ queueBody: document.getElementById('queueBody'),
751
+ resultModal: document.getElementById('resultModal'),
752
+ statusModal: document.getElementById('statusModal'),
753
+ resultText: document.getElementById('resultText'),
754
+ modalTitle: document.getElementById('modalTitle'),
755
+ copyBtn: document.getElementById('copyBtn'),
756
+ downloadBtn: document.getElementById('downloadBtn'),
757
+ audioContainer: document.getElementById('audioContainer'),
758
+ audioPlayer: document.getElementById('audioPlayer'),
759
+ statusMessage: document.getElementById('statusMessage'),
760
+ statusSubMessage: document.getElementById('statusSubMessage'),
761
+ statusIcon: document.getElementById('statusIcon'),
762
+ statusIconContainer: document.getElementById('statusIconContainer'),
763
+ statusBg: document.getElementById('statusBg'),
764
+ healthDot: document.getElementById('healthDot'),
765
+ healthText: document.getElementById('healthText'),
766
+ apiDocBtn: document.getElementById('apiDocBtn')
767
+ };
768
+
769
+ // --- Custom Dropdown Logic ---
770
+ UI.voiceTrigger.onclick = (e) => {
771
+ e.stopPropagation();
772
+ UI.voicePopup.classList.toggle('active');
773
+ };
774
+
775
+ document.querySelectorAll('.dropdown-option').forEach(option => {
776
+ option.onclick = (e) => {
777
+ const value = option.getAttribute('data-value');
778
+ const text = option.innerText;
779
+
780
+ UI.voiceSelect.value = value;
781
+ UI.selectedVoice.innerText = text;
782
+
783
+ document.querySelectorAll('.dropdown-option').forEach(opt => opt.classList.remove('selected'));
784
+ option.classList.add('selected');
785
+
786
+ UI.voicePopup.classList.remove('active');
787
+ };
788
+ });
789
+
790
+ window.onclick = () => {
791
+ UI.voicePopup.classList.remove('active');
792
+ };
793
+
794
+ // --- UI Helpers ---
795
+ function updateStatusModal(type, msg, subMsg) {
796
+ UI.statusMessage.innerText = msg;
797
+ UI.statusSubMessage.innerText = subMsg || "Processing your request, please wait.";
798
+ UI.statusIconContainer.className = "status-icon-container";
799
+ UI.statusIcon.className = "material-symbols-outlined text-6xl";
800
+ UI.statusBg.style.borderColor = "";
801
+
802
+ if (type === 'working') {
803
+ UI.statusIconContainer.classList.add('text-crayon-blue');
804
+ UI.statusIcon.innerText = "settings";
805
+ UI.statusIcon.classList.add('animate-spin');
806
+ UI.statusBg.style.borderColor = "#2563eb";
807
+ } else if (type === 'success') {
808
+ UI.statusIconContainer.classList.add('text-crayon-green');
809
+ UI.statusIcon.innerText = "check_circle";
810
+ UI.statusBg.style.borderColor = "#16a34a";
811
+ } else if (type === 'error') {
812
+ UI.statusIconContainer.classList.add('text-crayon-red');
813
+ UI.statusIcon.innerText = "error";
814
+ UI.statusBg.style.borderColor = "#dc2626";
815
  }
816
+ }
817
 
818
+ function closeModal() {
819
+ UI.resultModal.classList.remove('active');
820
+ UI.audioPlayer.pause();
821
+ }
822
 
823
+ function copyResult() {
824
+ const text = UI.resultText.innerText;
825
+ navigator.clipboard.writeText(text).then(() => {
826
+ const orig = UI.copyBtn.innerText;
827
+ UI.copyBtn.innerText = '✓ Copied!';
828
+ setTimeout(() => { UI.copyBtn.innerText = orig; }, 2000);
829
+ });
830
+ }
831
 
832
+ // --- API Functions ---
833
+ async function loadTasks() {
834
  try {
835
+ const res = await fetch(`${API_BASE}/tasks`);
836
+ const data = await res.json();
837
+ renderQueue(data);
838
+ } catch (err) {
839
+ console.error("Load tasks error:", err);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
840
  }
841
+ }
842
 
843
+ async function handleGenerate() {
844
+ const text = UI.textInput.value.trim();
845
+ if (!text) return;
 
846
 
847
+ UI.statusModal.classList.add('active');
848
+ updateStatusModal('working', "Queuing...", "Sending text to server...");
 
 
849
 
850
+ const formData = new FormData();
851
+ formData.append('text', text);
852
+ formData.append('voice', UI.voiceSelect.value);
853
+ formData.append('speed', UI.speedInput.value);
854
 
855
+ try {
856
+ const res = await fetch(`${API_BASE}/tasks/upload`, { method: 'POST', body: formData });
857
+ if (res.ok) {
858
+ updateStatusModal('success', "Queued! ✨", "Task created and starting soon.");
859
+ UI.textInput.value = '';
860
+ setTimeout(() => {
861
+ UI.statusModal.classList.remove('active');
862
+ loadTasks();
863
+ }, 1200);
864
+ } else {
865
+ updateStatusModal('error', "Failed ❌", "Could not create task.");
866
+ setTimeout(() => UI.statusModal.classList.remove('active'), 2000);
867
  }
868
+ } catch (err) {
869
+ console.error("Upload error:", err);
870
+ updateStatusModal('error', "Error ⚠️", "Could not reach server.");
871
+ setTimeout(() => UI.statusModal.classList.remove('active'), 2000);
872
+ }
873
+ }
874
 
875
+ async function handleFile(file) {
876
+ if (!file) return;
 
 
 
877
 
878
+ UI.statusModal.classList.add('active');
879
+ updateStatusModal('working', "Uploading...", "Reading text file...");
 
 
880
 
881
+ const formData = new FormData();
882
+ formData.append('file', file);
883
+ formData.append('voice', UI.voiceSelect.value);
884
+ formData.append('speed', UI.speedInput.value);
885
 
 
 
886
  try {
887
+ const res = await fetch(`${API_BASE}/tasks/upload`, { method: 'POST', body: formData });
888
+ if (res.ok) {
889
+ updateStatusModal('success', "Uploaded! ✨", "Task created from file.");
890
+ setTimeout(() => {
891
+ UI.statusModal.classList.remove('active');
892
+ loadTasks();
893
+ }, 1200);
894
+ } else {
895
+ updateStatusModal('error', "Failed ❌", "Invalid file or server error.");
896
+ setTimeout(() => UI.statusModal.classList.remove('active'), 2000);
 
897
  }
898
+ } catch (err) {
899
+ console.error("Upload error:", err);
900
+ updateStatusModal('error', "Error ⚠️", "Connection failed.");
901
+ setTimeout(() => UI.statusModal.classList.remove('active'), 2000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
902
  }
903
  }
904
 
905
+ async function showResult(id) {
906
+ try {
907
+ const res = await fetch(`${API_BASE}/tasks/${id}`);
908
+ const data = await res.json();
 
 
 
909
 
910
+ UI.modalTitle.innerText = "Speech Task Details";
911
+ UI.resultText.innerText = data.text || data.filename;
912
 
913
+ UI.copyBtn.classList.remove('hidden');
 
914
 
915
+ if (data.status === 'completed') {
916
+ UI.audioContainer.classList.remove('hidden');
917
+ UI.downloadBtn.classList.remove('hidden');
918
+ UI.audioPlayer.src = `${API_BASE}/download/${id}`;
919
+ UI.downloadBtn.href = `${API_BASE}/download/${id}`;
920
+ } else {
921
+ UI.audioContainer.classList.add('hidden');
922
+ UI.downloadBtn.classList.add('hidden');
923
+ }
 
924
 
925
+ UI.resultModal.classList.add('active');
926
+ } catch (err) {
927
+ console.error("Show result error:", err);
928
+ }
929
  }
930
 
931
+ async function checkHealth() {
932
+ try {
933
+ const res = await fetch('/health');
934
+ const data = await res.json();
935
+ const healthy = data.status === 'healthy';
936
+
937
+ UI.healthDot.className = `w-4 h-4 rounded-full ${healthy ? 'bg-crayon-green' : 'bg-crayon-red'} shadow-md`;
938
+ UI.healthText.innerText = healthy ? 'Service Online' : 'Service Down';
939
+ UI.healthText.className = `text-headline-md font-bold ${healthy ? 'text-crayon-green' : 'text-crayon-red'}`;
940
+ } catch (e) {
941
+ UI.healthDot.className = 'w-4 h-4 rounded-full bg-crayon-red shadow-md';
942
+ UI.healthText.innerText = 'Connection Error';
943
+ UI.healthText.className = 'text-headline-md font-bold text-crayon-red';
944
+ }
945
  }
946
 
947
+ // --- Renderers ---
948
+ function renderQueue(tasks) {
949
+ if (tasks.length === 0) {
950
+ UI.queueBody.innerHTML = '<div class="text-center py-32 text-headline-md text-[#94a3b8] font-bold opacity-60">No speech tasks found yet...</div>';
951
+ return;
952
+ }
 
 
 
 
 
953
 
954
+ UI.queueBody.innerHTML = tasks.map((t, i) => {
955
+ const rotate = i % 2 === 0 ? 'rotate-[0.3deg]' : '-rotate-[0.3deg]';
956
+ const status = t.status.toLowerCase();
957
+ const colors = {
958
+ completed: { text: 'crayon-green', bg: 'bg-[#f0fdf4]' },
959
+ failed: { text: 'crayon-red', bg: 'bg-[#fef2f2]' },
960
+ processing: { text: 'crayon-blue', bg: 'bg-[#eff6ff]' },
961
+ pending: { text: 'crayon-purple', bg: 'bg-[#f5f3ff]' },
962
+ not_started: { text: 'crayon-purple', bg: 'bg-[#f5f3ff]' }
963
+ };
964
+ const theme = colors[status] || colors.pending;
965
+ const snippet = t.filename || t.text || "Unnamed Task";
966
+
967
+ return `
968
+ <div class="task-card flex items-center p-6 bg-surface hover:border-crayon-purple transition-colors shadow-sm ${rotate}">
969
+ <div class="flex items-center gap-5 w-1/2">
970
+ <div class="w-14 h-16 border-[3px] border-crayon-blue rounded-xl p-2 bg-surface flex shadow-sm organic-shape rotate-[-3deg] shrink-0">
971
+ <div class="w-full h-full flex flex-col gap-1.5 p-0.5">
972
+ <div class="w-full h-1 bg-[#adc6ff] rounded-sm"></div>
973
+ <div class="w-full h-1 bg-[#adc6ff] rounded-sm"></div>
974
+ <div class="w-2/3 h-1 bg-[#adc6ff] rounded-sm"></div>
975
+ </div>
976
+ </div>
977
+ <div class="flex flex-col min-w-0">
978
+ <span class="text-headline-md text-[#1A1A1A] leading-tight font-bold mb-1 truncate">${snippet}</span>
979
+ <div class="text-label-sm text-[#94a3b8] font-bold">${t.id.substring(0, 12)}</div>
980
+ </div>
981
+ </div>
982
+ <div class="w-1/6 flex justify-center">
983
+ <div class="px-4 py-2 ${theme.bg} border-[3px] border-${theme.text} text-${theme.text} font-bold rounded-2xl uppercase tracking-tight crayon-filter text-sm">
984
+ ${status.replace('_', ' ')}
985
+ </div>
986
+ </div>
987
+ <div class="w-1/4 flex items-center justify-center gap-4">
988
+ <div class="flex-1 h-5 border-[3px] border-[#adc6ff] rounded-full overflow-hidden bg-surface p-[1px] crayon-filter">
989
+ <div class="h-full rounded-full progress-fill shadow-sm" style="width:${t.progress}%"></div>
990
+ </div>
991
+ <span class="text-headline-md text-xl text-[#1A1A1A] font-bold w-12 text-right">${status === 'completed' ? '' : t.progress + '%'}</span>
992
+ </div>
993
+ <div class="w-[10%] flex justify-center">
994
+ <button onclick="showResult('${t.id}')" class="flex items-center gap-2 px-4 py-2 bg-white border-[3px] border-crayon-blue text-crayon-blue font-bold rounded-2xl hover:bg-crayon-blue hover:text-white transition-all shadow-sm crayon-filter text-sm">
995
+ <span class="material-symbols-outlined text-xl">${status === 'completed' ? 'play_arrow' : 'visibility'}</span>
996
+ ${status === 'completed' ? 'PLAY' : 'VIEW'}
997
+ </button>
998
+ </div>
999
  </div>
1000
+ `;
1001
+ }).join('');
1002
+ }
1003
+
1004
+ // --- Event Listeners ---
1005
+ UI.generateBtn.onclick = handleGenerate;
1006
+ UI.uploadZone.onclick = () => UI.fileInput.click();
1007
+ UI.uploadZone.ondragover = (e) => { e.preventDefault(); UI.uploadZone.classList.add('dragging'); };
1008
+ UI.uploadZone.ondragleave = () => UI.uploadZone.classList.remove('dragging');
1009
+ UI.uploadZone.ondrop = (e) => {
1010
+ e.preventDefault();
1011
+ UI.uploadZone.classList.remove('dragging');
1012
+ handleFile(e.dataTransfer.files[0]);
1013
+ };
1014
+ UI.fileInput.onchange = (e) => handleFile(e.target.files[0]);
1015
+ UI.apiDocBtn.onclick = () => {
1016
+ const doc = {
1017
+ base_url: window.location.origin,
1018
+ endpoints: [
1019
+ { method: "POST", path: "/api/tasks/upload", desc: "Create TTS task", params: { text: "string", voice: "1-14", speed: "0.5-2.0" } },
1020
+ { method: "POST", path: "/api/tasks/upload", desc: "Create TTS task via file", params: { file: "UploadFile", voice: "1-14", speed: "0.5-2.0" } },
1021
+ { method: "GET", path: "/api/tasks", desc: "List all tasks" },
1022
+ { method: "GET", path: "/api/tasks/{task_id}", desc: "Get task details" },
1023
+ { method: "GET", path: "/api/download/{task_id}", desc: "Download audio file" },
1024
+ { method: "GET", path: "/health", desc: "Service health" }
1025
+ ],
1026
+ example_usage: `curl -X POST -F 'text=Hello world' -F 'voice=4' ${window.location.origin}/api/tasks/upload`
1027
  };
1028
+ UI.resultText.innerText = JSON.stringify(doc, null, 2);
1029
+ UI.modalTitle.innerText = "API Documentation";
1030
+ UI.audioContainer.classList.add('hidden');
1031
+ UI.downloadBtn.classList.add('hidden');
1032
+ UI.copyBtn.classList.remove('hidden');
1033
+ UI.resultModal.classList.add('active');
1034
+ };
1035
+
1036
+ // --- Lifecycle ---
1037
+ loadTasks();
1038
+ setInterval(loadTasks, 5000);
1039
+ setInterval(checkHealth, 10000);
 
 
 
1040
  </script>
1041
  </body>
1042
 
pyproject.toml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tts-runner-backend"
7
+ version = "2.0.0"
8
+ description = "FastAPI backend for TTS Runner"
9
+ dependencies = [
10
+ "fastapi",
11
+ "uvicorn",
12
+ "aiosqlite",
13
+ "aiofiles",
14
+ "python-multipart",
15
+ "custom_logger @ git+https://github.com/jebin2/custom_logger.git",
16
+ "tts-runner[chatterbox] @ git+https://github.com/jebin2/TTS.git"
17
+ ]
18
+
19
+ [project.scripts]
20
+ tts-backend = "app.main:app"
21
+
22
+ [tool.setuptools.packages.find]
23
+ include = ["app*"]
24
+ exclude = ["uploads*", "temp_dir*"]
requirements.txt DELETED
@@ -1,5 +0,0 @@
1
- Flask==3.0.0
2
- flask-cors==4.0.0
3
- werkzeug==3.0.1
4
-
5
- git+https://github.com/jebin2/TTS.git#egg=tts-runner[chatterbox]
 
 
 
 
 
 
run.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import uvicorn
2
+ from app.core.config import settings
3
+
4
+ if __name__ == "__main__":
5
+ uvicorn.run("app.main:app", host="0.0.0.0", port=settings.PORT, reload=False)
setup.py DELETED
@@ -1,77 +0,0 @@
1
- # setup.py
2
- import os
3
- from setuptools import setup, find_packages
4
-
5
- # Read README.md
6
- this_directory = os.path.abspath(os.path.dirname(__file__))
7
- with open(os.path.join(this_directory, 'README.md'), encoding='utf-8') as f:
8
- long_description = f.read()
9
-
10
- # Base dependencies
11
- BASE_DEPS = [
12
- 'numpy',
13
- 'torch',
14
- 'pydub',
15
- 'sounddevice',
16
- 'python-dotenv',
17
- # 'textual', # From requirement_tui.txt
18
- # 'pyperclip', # From requirement_tui.txt
19
- 'scipy' # Implicit dependency for wavfile reading in base
20
- ]
21
-
22
- # Optional extras (engines)
23
- extras_require = {
24
- "chatterbox": [
25
- "chatterbox-tts",
26
- "spacy",
27
- "peft"
28
- ],
29
- "kitten": [
30
- "kittentts",
31
- "spacy"
32
- ],
33
- "kokoro": [
34
- "kokoro>=0.9.4",
35
- "soundfile"
36
- ],
37
- }
38
-
39
- # All extras
40
- all_deps = []
41
- for deps in extras_require.values():
42
- all_deps.extend(deps)
43
- extras_require["all"] = list(set(all_deps))
44
-
45
- setup(
46
- name="tts-runner",
47
- version="1.0.0",
48
- author="Jebin Einstein",
49
- author_email="jebin@gmail.com",
50
- description="A flexible, multi-engine Text-to-Speech runner with TUI",
51
- long_description=long_description,
52
- long_description_content_type="text/markdown",
53
- url="https://github.com/jebin2/TTS",
54
-
55
- packages=find_packages(),
56
- include_package_data=True,
57
-
58
- install_requires=BASE_DEPS,
59
- extras_require=extras_require,
60
-
61
- entry_points={
62
- "console_scripts": [
63
- "tts-runner=tts_runner.runner:main",
64
- "tts-tui=tts_runner.tui:main",
65
- ],
66
- },
67
-
68
- classifiers=[
69
- "Programming Language :: Python :: 3",
70
- "License :: OSI Approved :: MIT License",
71
- "Operating System :: OS Independent",
72
- "Topic :: Multimedia :: Sound/Audio :: Speech",
73
- "Topic :: Scientific/Engineering :: Artificial Intelligence",
74
- ],
75
-
76
- python_requires=">=3.10",
77
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tts_results.db ADDED
Binary file (12.3 kB). View file
 
tts_runner/base.py CHANGED
@@ -42,8 +42,8 @@ class BaseTTS:
42
  self.temp_output_dir.mkdir(parents=True, exist_ok=True)
43
 
44
  # Voice and speed configuration
45
- self.default_voice_index = 8
46
- self.default_speed = 0.8
47
 
48
  self.voices = [
49
  None,
 
42
  self.temp_output_dir.mkdir(parents=True, exist_ok=True)
43
 
44
  # Voice and speed configuration
45
+ self.default_voice_index = 4
46
+ self.default_speed = 1.0
47
 
48
  self.voices = [
49
  None,
tts_runner_backend.egg-info/PKG-INFO ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: tts-runner-backend
3
+ Version: 2.0.0
4
+ Summary: FastAPI backend for TTS Runner
5
+ Requires-Dist: fastapi
6
+ Requires-Dist: uvicorn
7
+ Requires-Dist: aiosqlite
8
+ Requires-Dist: aiofiles
9
+ Requires-Dist: python-multipart
10
+ Requires-Dist: custom_logger @ git+https://github.com/jebin2/custom_logger.git
11
+ Requires-Dist: tts-runner[chatterbox] @ git+https://github.com/jebin2/TTS.git
tts_runner_backend.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ README.md
2
+ pyproject.toml
3
+ app/main.py
4
+ app/api/routes.py
5
+ app/core/config.py
6
+ app/db/crud.py
7
+ app/db/database.py
8
+ app/services/worker.py
9
+ tts_runner_backend.egg-info/PKG-INFO
10
+ tts_runner_backend.egg-info/SOURCES.txt
11
+ tts_runner_backend.egg-info/dependency_links.txt
12
+ tts_runner_backend.egg-info/entry_points.txt
13
+ tts_runner_backend.egg-info/requires.txt
14
+ tts_runner_backend.egg-info/top_level.txt
tts_runner_backend.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
tts_runner_backend.egg-info/entry_points.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [console_scripts]
2
+ tts-backend = app.main:app
tts_runner_backend.egg-info/requires.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ aiosqlite
4
+ aiofiles
5
+ python-multipart
6
+ custom_logger @ git+https://github.com/jebin2/custom_logger.git
7
+ tts-runner[chatterbox] @ git+https://github.com/jebin2/TTS.git
tts_runner_backend.egg-info/top_level.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ app
worker.py DELETED
@@ -1,138 +0,0 @@
1
- import sqlite3
2
- import time
3
- import os
4
- import subprocess
5
- import json
6
- import shlex
7
- from datetime import datetime
8
-
9
- CWD = "./"
10
- PYTHON_PATH = "stt-transcribe"
11
- STT_MODEL_NAME = "fasterwhispher"
12
- POLL_INTERVAL = 3 # seconds
13
-
14
- def process_audio(file_id, filepath):
15
- """Process audio file using STT and return the transcription"""
16
- try:
17
- print(f"🔄 Running STT on: {os.path.abspath(filepath)}")
18
-
19
- # Run STT command
20
- command = f"""cd {CWD} && {PYTHON_PATH} --input {shlex.quote(os.path.abspath(filepath))} --model {STT_MODEL_NAME}"""
21
-
22
- subprocess.run(
23
- command,
24
- shell=True,
25
- executable="/bin/bash",
26
- check=True,
27
- cwd=CWD,
28
- env={
29
- **os.environ,
30
- 'PYTHONUNBUFFERED': '1',
31
- 'CUDA_LAUNCH_BLOCKING': '1',
32
- 'USE_CPU_IF_POSSIBLE': 'true'
33
- }
34
- )
35
-
36
- # Read transcription result
37
- output_path = f'{CWD}/temp_dir/output_transcription.json'
38
- with open(output_path, 'r') as file:
39
- result = json.loads(file.read().strip())
40
-
41
- # Extract caption text (adjust based on your actual output format)
42
- caption = result.get('text', '') or result.get('transcription', '') or str(result)
43
-
44
- return caption, None
45
-
46
- except Exception as e:
47
- print(f"❌ Error processing file {file_id}: {str(e)}")
48
- return None, str(e)
49
-
50
- def update_status(file_id, status, caption=None, error=None):
51
- """Update the status of a file in the database"""
52
- conn = sqlite3.connect('audio_captions.db')
53
- c = conn.cursor()
54
-
55
- if status == 'completed':
56
- c.execute('''UPDATE audio_files
57
- SET status = ?, caption = ?, processed_at = ?
58
- WHERE id = ?''',
59
- (status, caption, datetime.now().isoformat(), file_id))
60
- elif status == 'failed':
61
- c.execute('''UPDATE audio_files
62
- SET status = ?, caption = ?, processed_at = ?
63
- WHERE id = ?''',
64
- (status, f"Error: {error}", datetime.now().isoformat(), file_id))
65
- else:
66
- c.execute('UPDATE audio_files SET status = ? WHERE id = ?', (status, file_id))
67
-
68
- conn.commit()
69
- conn.close()
70
-
71
- def worker_loop():
72
- """Main worker loop that processes audio files"""
73
- print("🤖 STT Worker started. Monitoring for new audio files...")
74
- print("🗑️ Audio files will be deleted after successful processing\n")
75
-
76
- while True:
77
- try:
78
- # Get next unprocessed file
79
- conn = sqlite3.connect('audio_captions.db')
80
- conn.row_factory = sqlite3.Row
81
- c = conn.cursor()
82
- c.execute('''SELECT * FROM audio_files
83
- WHERE status = 'not_started'
84
- ORDER BY created_at ASC
85
- LIMIT 1''')
86
- row = c.fetchone()
87
- conn.close()
88
-
89
- if row:
90
- file_id = row['id']
91
- filepath = row['filepath']
92
- filename = row['filename']
93
-
94
- print(f"\n{'='*60}")
95
- print(f"🎵 Processing: {filename}")
96
- print(f"📝 ID: {file_id}")
97
- print(f"{'='*60}")
98
-
99
- # Update status to processing
100
- update_status(file_id, 'processing')
101
-
102
- # Process the audio file
103
- caption, error = process_audio(file_id, filepath)
104
-
105
- if caption:
106
- print(f"✅ Successfully processed: {filename}")
107
- print(f"📄 Caption preview: {caption[:100]}...")
108
- update_status(file_id, 'completed', caption=caption)
109
-
110
- # Delete the audio file after successful processing
111
- if os.path.exists(filepath):
112
- os.remove(filepath)
113
- print(f"🗑️ Deleted audio file: {filepath}")
114
- else:
115
- print(f"❌ Failed to process: {filename}")
116
- print(f"Error: {error}")
117
- update_status(file_id, 'failed', error=error)
118
- # Don't delete file on failure (for debugging)
119
- else:
120
- # No files to process, sleep for a bit
121
- time.sleep(POLL_INTERVAL)
122
-
123
- except Exception as e:
124
- print(f"⚠️ Worker error: {str(e)}")
125
- time.sleep(POLL_INTERVAL)
126
-
127
- if __name__ == '__main__':
128
- # Initialize database if it doesn't exist
129
- if not os.path.exists('audio_captions.db'):
130
- print("❌ Database not found. Please run app.py first to initialize.")
131
- else:
132
- print("\n" + "="*60)
133
- print("🚀 Starting STT Worker (Standalone Mode)")
134
- print("="*60)
135
- print("⚠️ Note: Worker is now embedded in app.py")
136
- print("⚠️ This standalone mode is for testing/debugging only")
137
- print("="*60 + "\n")
138
- worker_loop()