johnbridges commited on
Commit
aea6642
·
1 Parent(s): 94f247f

fixed token limit

Browse files
Files changed (1) hide show
  1. app.py +470 -94
app.py CHANGED
@@ -13,11 +13,15 @@ import logging
13
  from flask_cors import CORS
14
  import re
15
  import threading
 
16
  import werkzeug
17
  import tempfile
 
 
18
  from huggingface_hub import snapshot_download
19
  from tts_processor import preprocess_all
20
  import hashlib
 
21
  import os
22
  import torch
23
  import numpy as np
@@ -46,7 +50,11 @@ sess_options.inter_op_num_threads = 1
46
 
47
 
48
  # Configure logging
49
- logging.basicConfig(level=logging.INFO)
 
 
 
 
50
  logger = logging.getLogger(__name__)
51
 
52
  app = Flask(__name__)
@@ -141,52 +149,154 @@ ASR_ONNX_REPO = os.environ.get("ASR_ONNX_REPO", "onnx-community/wav2vec2-base-96
141
  PUNCTUATE_TEXT = os.environ.get("PUNCTUATE_TEXT", "0").lower() in {"1", "true", "yes", "on"}
142
  TECH_NORMALIZE = os.environ.get("TECH_NORMALIZE", "0").lower() in {"1", "true", "yes", "on"}
143
  PUNCTUATION_MODEL = os.environ.get("PUNCTUATION_MODEL", "kredor/punctuate-all")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
  # Initialize models
146
- def initialize_models():
147
  global sess, voice_style, processor, whisper_model, asr_session, asr_processor
 
148
  global punctuation_model, punctuation_tokenizer
149
 
150
  try:
151
- # Download the ONNX model if not already downloaded
152
- if not os.path.exists(model_path):
153
- logger.info("Downloading and loading Kokoro model...")
154
- kokoro_dir = snapshot_download(kokoro_model_id, cache_dir=model_path)
155
- logger.info(f"Kokoro model directory: {kokoro_dir}")
156
- else:
157
- kokoro_dir = model_path
158
- logger.info(f"Using cached Kokoro model directory: {kokoro_dir}")
159
-
160
- # Validate ONNX file path
161
- onnx_path = None
162
- for root, _, files in os.walk(kokoro_dir):
163
- if 'model.onnx' in files:
164
- onnx_path = os.path.join(root, 'model.onnx')
165
- break
 
 
 
 
166
 
167
- if not onnx_path or not os.path.exists(onnx_path):
168
- raise FileNotFoundError(f"ONNX file not found after redownload at {kokoro_dir}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
- logger.info("Loading ONNX session...")
171
- sess = InferenceSession(onnx_path, sess_options)
172
- logger.info(f"ONNX session loaded successfully from {onnx_path}")
173
 
174
- # Load the voice style vector
175
- voice_style_path = None
176
- for root, _, files in os.walk(kokoro_dir):
177
- if f'{voice_name}.bin' in files:
178
- voice_style_path = os.path.join(root, f'{voice_name}.bin')
179
- break
 
180
 
181
- if not voice_style_path or not os.path.exists(voice_style_path):
182
- raise FileNotFoundError(f"Voice style file not found at {voice_style_path}")
 
 
 
 
 
 
183
 
184
- logger.info("Loading voice style vector...")
185
- voice_style = np.fromfile(voice_style_path, dtype=np.float32).reshape(-1, 1, 256)
186
- logger.info(f"Voice style vector loaded successfully from {voice_style_path}")
187
 
188
  # Initialize ASR engine
189
- if ASR_ENGINE == "wav2vec2_onnx":
190
  logger.info(f"Loading Wav2Vec2 ONNX ASR model ({ASR_MODEL_NAME})...")
191
  # Load processor for feature extraction + CTC labels
192
  asr_processor = Wav2Vec2Processor.from_pretrained(ASR_MODEL_NAME)
@@ -230,14 +340,14 @@ def initialize_models():
230
  raise
231
  asr_session = InferenceSession(asr_onnx_path_env, sess_options)
232
  logger.info("Wav2Vec2 ONNX ASR model loaded")
233
- else:
234
  logger.info("ASR_ENGINE set to whisper_pt; loading Whisper model...")
235
  processor = WhisperProcessor.from_pretrained("openai/whisper-base")
236
  whisper_model = WhisperForConditionalGeneration.from_pretrained("openai/whisper-base")
237
  whisper_model.config.forced_decoder_ids = None
238
  logger.info("Whisper model loaded successfully")
239
 
240
- if PUNCTUATE_TEXT:
241
  logger.info(f"Loading punctuation model ({PUNCTUATION_MODEL})...")
242
  punctuation_tokenizer = AutoTokenizer.from_pretrained(PUNCTUATION_MODEL)
243
  punctuation_model = AutoModelForTokenClassification.from_pretrained(PUNCTUATION_MODEL)
@@ -248,8 +358,167 @@ def initialize_models():
248
  logger.error(f"Error initializing models: {str(e)}")
249
  raise
250
 
251
- # Initialize models
252
- initialize_models()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
 
254
  def restore_punctuation(text, max_words=120):
255
  if not PUNCTUATE_TEXT:
@@ -408,82 +677,153 @@ def health_check():
408
  @app.route('/generate_audio', methods=['POST'])
409
  def generate_audio():
410
  """Text-to-Speech (T2S) Endpoint"""
411
- with global_lock:
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  try:
413
- logger.debug("Received request to /generate_audio")
414
- data = request.json
415
- text = data['text']
416
 
417
  validate_text_input(text)
418
 
419
- # Preprocess & stable hash
420
- text = preprocess_all(text)
421
- text_hash = hashlib.sha256(text.encode('utf-8')).hexdigest()
 
 
 
 
 
 
 
 
 
422
  filename = f"{text_hash}.wav"
423
  cached_file_path = os.path.join(SERVE_DIR, filename)
424
 
425
  # Cache hit
426
  if is_cached(cached_file_path):
427
- logger.info("Returning cached audio")
428
  return jsonify({"status": "success", "filename": filename})
429
 
430
- # Tokenize
431
- from kokoro import phonemize, tokenize # lazy import is fine
432
- tokens = tokenize(phonemize(text, 'a'))
433
- if len(tokens) > 510:
434
- logger.warning("Text too long; truncating to 510 tokens.")
435
- tokens = tokens[:510]
436
- tokens = [[0, *tokens, 0]]
437
-
438
- # Style vector
439
- ref_s = voice_style[len(tokens[0]) - 2] # (1,256)
440
-
441
- # ONNX inference
442
- audio = sess.run(None, dict(
443
- input_ids=np.array(tokens, dtype=np.int64),
444
- style=ref_s,
445
- speed=np.ones(1, dtype=np.float32),
446
- ))[0]
447
-
448
- # Save
449
- audio = np.squeeze(audio).astype(np.float32)
450
- sf.write(cached_file_path, audio, 24000)
451
-
452
- logger.info(f"Audio saved: {cached_file_path}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  return jsonify({"status": "success", "filename": filename})
454
  except Exception as e:
455
- logger.error(f"Error generating audio: {str(e)}")
 
456
  return jsonify({"status": "error", "message": str(e)}), 500
 
 
 
 
457
 
458
  # Speech-to-Text (S2T) Endpoint
459
- # Add these imports at the top with the other imports
460
- import subprocess
461
- import tempfile
462
- from pathlib import Path
463
-
464
- # Then update the transcribe_audio function:
465
  @app.route('/transcribe_audio', methods=['POST'])
466
  def transcribe_audio():
467
  """Speech-to-Text (S2T) Endpoint with automatic format conversion"""
468
- with global_lock: # Acquire global lock to ensure only one instance runs
469
- input_audio_path = None
470
- converted_audio_path = None
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  try:
472
- logger.debug("Received request to /transcribe_audio")
 
 
 
473
  file = request.files['file']
 
 
 
 
 
474
 
475
  # Create temporary files for both input and output
476
  with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename).suffix) as input_temp:
477
  input_audio_path = input_temp.name
478
  file.save(input_audio_path)
479
- logger.debug(f"Original audio file saved to {input_audio_path}")
 
480
 
481
  # Create a temporary file for the converted WAV
482
  with tempfile.NamedTemporaryFile(delete=False, suffix='.wav') as output_temp:
483
  converted_audio_path = output_temp.name
484
 
485
  # Convert to WAV with ffmpeg (16kHz, mono)
486
- logger.debug(f"Converting audio to 16kHz mono WAV format...")
487
  conversion_command = [
488
  'ffmpeg',
489
  '-y', # Force overwrite without prompting
@@ -494,24 +834,42 @@ def transcribe_audio():
494
  '-af', 'highpass=f=80,lowpass=f=7500,afftdn=nr=10:nf=-25,loudnorm=I=-16:TP=-1.5:LRA=11', # Audio cleanup filters
495
  converted_audio_path
496
  ]
497
- result = subprocess.run(
498
- conversion_command,
499
- stdout=subprocess.PIPE,
500
- stderr=subprocess.PIPE,
501
- text=True
502
- )
503
-
 
 
 
 
 
 
 
 
504
  if result.returncode != 0:
505
  logger.error(f"FFmpeg conversion error: {result.stderr}")
506
  raise Exception(f"Audio conversion failed: {result.stderr}")
507
 
508
- logger.debug(f"Audio successfully converted to {converted_audio_path}")
 
 
 
 
509
 
510
  # Load and process the converted audio
511
- logger.debug("Processing audio for transcription...")
512
  audio_array, sampling_rate = librosa.load(converted_audio_path, sr=16000)
 
 
 
 
 
513
 
514
  if ASR_ENGINE == "wav2vec2_onnx" and 'asr_session' in globals() and asr_session is not None:
 
515
  # Prepare input for Wav2Vec2 ONNX: float32 PCM, shape (batch, samples)
516
  inputs = asr_processor(audio_array, sampling_rate=16000, return_tensors="np")
517
  # Some exports expect input as (batch, sequence); adjust key as needed
@@ -532,8 +890,13 @@ def transcribe_audio():
532
  # Collapse repeats and remove CTC blank (id 0 for many models; rely on processor)
533
  transcription = asr_processor.batch_decode(pred_ids)[0]
534
  transcription = transcription.strip()
535
- logger.info(f"Transcription (Wav2Vec2 ONNX): {transcription}")
 
 
 
 
536
  else:
 
537
  # Whisper fallback
538
  input_features = processor(
539
  audio_array,
@@ -544,7 +907,11 @@ def transcribe_audio():
544
  logger.debug("Generating transcription (Whisper)...")
545
  predicted_ids = whisper_model.generate(input_features)
546
  transcription = processor.batch_decode(predicted_ids, skip_special_tokens=True)[0]
547
- logger.info(f"Transcription (Whisper): {transcription}")
 
 
 
 
548
 
549
  if PUNCTUATE_TEXT:
550
  try:
@@ -560,9 +927,14 @@ def transcribe_audio():
560
  except Exception as ne:
561
  logger.warning(f"Tech normalization failed: {ne}")
562
 
 
 
 
 
563
  return jsonify({"status": "success", "transcription": transcription})
564
  except Exception as e:
565
- logger.error(f"Error transcribing audio: {str(e)}")
 
566
  return jsonify({"status": "error", "message": str(e)}), 500
567
  finally:
568
  # Clean up temporary files
@@ -573,6 +945,10 @@ def transcribe_audio():
573
  logger.debug(f"Temporary file {path} removed")
574
  except Exception as e:
575
  logger.warning(f"Failed to remove temporary file {path}: {e}")
 
 
 
 
576
 
577
  @app.route('/files/<filename>', methods=['GET'])
578
  def serve_wav_file(filename):
 
13
  from flask_cors import CORS
14
  import re
15
  import threading
16
+ import time
17
  import werkzeug
18
  import tempfile
19
+ import subprocess
20
+ from pathlib import Path
21
  from huggingface_hub import snapshot_download
22
  from tts_processor import preprocess_all
23
  import hashlib
24
+ import shutil
25
  import os
26
  import torch
27
  import numpy as np
 
50
 
51
 
52
  # Configure logging
53
+ LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
54
+ logging.basicConfig(
55
+ level=getattr(logging, LOG_LEVEL, logging.INFO),
56
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
57
+ )
58
  logger = logging.getLogger(__name__)
59
 
60
  app = Flask(__name__)
 
149
  PUNCTUATE_TEXT = os.environ.get("PUNCTUATE_TEXT", "0").lower() in {"1", "true", "yes", "on"}
150
  TECH_NORMALIZE = os.environ.get("TECH_NORMALIZE", "0").lower() in {"1", "true", "yes", "on"}
151
  PUNCTUATION_MODEL = os.environ.get("PUNCTUATION_MODEL", "kredor/punctuate-all")
152
+ KOKORO_ONNX_MODE = os.environ.get("KOKORO_ONNX_MODE", "auto").lower() # auto | legacy | stts2 | piper
153
+ KOKORO_SPEAKER_ID = int(os.environ.get("KOKORO_SPEAKER_ID", "0"))
154
+ KOKORO_MAX_PHONEME_TOKENS = int(os.environ.get("KOKORO_MAX_PHONEME_TOKENS", "510"))
155
+ KOKORO_STTS2_NOISE_SCALE = float(os.environ.get("KOKORO_STTS2_NOISE_SCALE", "0.667"))
156
+ KOKORO_STTS2_LENGTH_SCALE = float(os.environ.get("KOKORO_STTS2_LENGTH_SCALE", "1.0"))
157
+ KOKORO_STTS2_NOISE_W = float(os.environ.get("KOKORO_STTS2_NOISE_W", "0.8"))
158
+ PIPER_BIN = os.environ.get("PIPER_BIN", "piper")
159
+ PIPER_MODEL_PATH = os.environ.get("PIPER_MODEL_PATH", "")
160
+ PIPER_CONFIG_PATH = os.environ.get("PIPER_CONFIG_PATH", "")
161
+ PIPER_AUTO_DOWNLOAD = os.environ.get("PIPER_AUTO_DOWNLOAD", "1").lower() in {"1", "true", "yes", "on"}
162
+ PIPER_REPO_ID = os.environ.get("PIPER_REPO_ID", "campwill/HAL-9000-Piper-TTS")
163
+ PIPER_REPO_MODEL_FILE = os.environ.get("PIPER_REPO_MODEL_FILE", "hal.onnx")
164
+ PIPER_REPO_CONFIG_FILE = os.environ.get("PIPER_REPO_CONFIG_FILE", "hal.onnx.json")
165
+ PIPER_DOWNLOAD_DIR = os.environ.get("PIPER_DOWNLOAD_DIR", os.path.join(model_path, "piper_repo"))
166
+ MODEL_INIT_MODE = os.environ.get("MODEL_INIT_MODE", "lazy").lower() # lazy | startup
167
+ PIPER_PREFETCH_ON_STARTUP = os.environ.get("PIPER_PREFETCH_ON_STARTUP", "0").lower() in {"1", "true", "yes", "on"}
168
+ REQUEST_LOCK_TIMEOUT_SEC = float(os.environ.get("REQUEST_LOCK_TIMEOUT_SEC", "15"))
169
+ PIPER_TIMEOUT_SEC = float(os.environ.get("PIPER_TIMEOUT_SEC", "120"))
170
+ FFMPEG_TIMEOUT_SEC = float(os.environ.get("FFMPEG_TIMEOUT_SEC", "60"))
171
+
172
+ sess = None
173
+ voice_style = None
174
+ processor = None
175
+ whisper_model = None
176
+ asr_session = None
177
+ asr_processor = None
178
+ punctuation_model = None
179
+ punctuation_tokenizer = None
180
+ kokoro_tts_mode = None
181
+ kokoro_input_names = set()
182
+ model_init_lock = threading.Lock()
183
 
184
  # Initialize models
185
+ def initialize_models(init_tts=True, init_asr=True):
186
  global sess, voice_style, processor, whisper_model, asr_session, asr_processor
187
+ global kokoro_tts_mode, kokoro_input_names
188
  global punctuation_model, punctuation_tokenizer
189
 
190
  try:
191
+ if os.path.exists(model_path) and not os.path.isdir(model_path):
192
+ raise FileExistsError(f"Model path exists but is not a directory: {model_path}")
193
+
194
+ def find_onnx(search_root):
195
+ for root, _, files in os.walk(search_root):
196
+ if "model.onnx" in files:
197
+ return os.path.join(root, "model.onnx")
198
+ return None
199
+
200
+ def download_kokoro():
201
+ allow_patterns_env = os.environ.get("KOKORO_ALLOW_PATTERNS", "")
202
+ if allow_patterns_env.strip():
203
+ allow_patterns = [p.strip() for p in allow_patterns_env.split(",") if p.strip()]
204
+ else:
205
+ allow_patterns = [
206
+ "**/model.onnx",
207
+ f"**/{voice_name}.bin",
208
+ "config.json",
209
+ ]
210
 
211
+ logger.info("Downloading and loading Kokoro model...")
212
+ try:
213
+ import inspect
214
+ sig = inspect.signature(snapshot_download)
215
+ if "local_dir" in sig.parameters:
216
+ return snapshot_download(
217
+ kokoro_model_id,
218
+ local_dir=model_path,
219
+ local_dir_use_symlinks=False,
220
+ allow_patterns=allow_patterns,
221
+ resume_download=True,
222
+ )
223
+ return snapshot_download(
224
+ kokoro_model_id,
225
+ cache_dir=model_path,
226
+ allow_patterns=allow_patterns,
227
+ resume_download=True,
228
+ )
229
+ except Exception:
230
+ return snapshot_download(
231
+ kokoro_model_id,
232
+ cache_dir=model_path,
233
+ allow_patterns=allow_patterns,
234
+ resume_download=True,
235
+ )
236
+
237
+ if init_tts and kokoro_tts_mode is None:
238
+ if KOKORO_ONNX_MODE == "piper":
239
+ # Piper mode performs synthesis through the Piper CLI and does not use ORT here.
240
+ sess = None
241
+ kokoro_input_names = set()
242
+ kokoro_tts_mode = "piper"
243
+ else:
244
+ kokoro_dir = model_path if os.path.exists(model_path) else None
245
+ onnx_path = find_onnx(kokoro_dir) if kokoro_dir else None
246
+
247
+ if not onnx_path:
248
+ kokoro_dir = download_kokoro()
249
+ logger.info(f"Kokoro model directory: {kokoro_dir}")
250
+ onnx_path = find_onnx(kokoro_dir)
251
+
252
+ if not onnx_path or not os.path.exists(onnx_path):
253
+ raise FileNotFoundError(f"ONNX file not found after redownload at {kokoro_dir}")
254
+
255
+ logger.info("Loading ONNX session...")
256
+ sess = InferenceSession(onnx_path, sess_options)
257
+ logger.info(f"ONNX session loaded successfully from {onnx_path}")
258
+ kokoro_input_names = {i.name for i in sess.get_inputs()}
259
+ if KOKORO_ONNX_MODE == "auto":
260
+ if {"input_ids", "style", "speed"}.issubset(kokoro_input_names):
261
+ kokoro_tts_mode = "legacy"
262
+ elif {"input", "input_lengths"}.issubset(kokoro_input_names):
263
+ kokoro_tts_mode = "stts2"
264
+ else:
265
+ raise RuntimeError(
266
+ f"Could not auto-detect ONNX input mode from inputs: {sorted(kokoro_input_names)}. "
267
+ "Set KOKORO_ONNX_MODE=legacy or KOKORO_ONNX_MODE=stts2."
268
+ )
269
+ elif KOKORO_ONNX_MODE in {"legacy", "stts2"}:
270
+ kokoro_tts_mode = KOKORO_ONNX_MODE
271
+ else:
272
+ raise ValueError("KOKORO_ONNX_MODE must be one of: auto, legacy, stts2, piper")
273
 
274
+ logger.info(f"Kokoro ONNX input names: {sorted(kokoro_input_names) if kokoro_input_names else []}")
275
+ logger.info(f"Kokoro ONNX mode: {kokoro_tts_mode}")
 
276
 
277
+ if kokoro_tts_mode == "legacy":
278
+ # Legacy Kokoro ONNX expects a style embedding from voice .bin
279
+ voice_style_path = None
280
+ for root, _, files in os.walk(kokoro_dir):
281
+ if f'{voice_name}.bin' in files:
282
+ voice_style_path = os.path.join(root, f'{voice_name}.bin')
283
+ break
284
 
285
+ if not voice_style_path or not os.path.exists(voice_style_path):
286
+ kokoro_dir = download_kokoro()
287
+ for root, _, files in os.walk(kokoro_dir):
288
+ if f'{voice_name}.bin' in files:
289
+ voice_style_path = os.path.join(root, f'{voice_name}.bin')
290
+ break
291
+ if not voice_style_path or not os.path.exists(voice_style_path):
292
+ raise FileNotFoundError(f"Voice style file not found at {voice_style_path}")
293
 
294
+ logger.info("Loading voice style vector...")
295
+ voice_style = np.fromfile(voice_style_path, dtype=np.float32).reshape(-1, 1, 256)
296
+ logger.info(f"Voice style vector loaded successfully from {voice_style_path}")
297
 
298
  # Initialize ASR engine
299
+ if init_asr and ASR_ENGINE == "wav2vec2_onnx" and asr_session is None:
300
  logger.info(f"Loading Wav2Vec2 ONNX ASR model ({ASR_MODEL_NAME})...")
301
  # Load processor for feature extraction + CTC labels
302
  asr_processor = Wav2Vec2Processor.from_pretrained(ASR_MODEL_NAME)
 
340
  raise
341
  asr_session = InferenceSession(asr_onnx_path_env, sess_options)
342
  logger.info("Wav2Vec2 ONNX ASR model loaded")
343
+ elif init_asr and ASR_ENGINE != "wav2vec2_onnx" and (processor is None or whisper_model is None):
344
  logger.info("ASR_ENGINE set to whisper_pt; loading Whisper model...")
345
  processor = WhisperProcessor.from_pretrained("openai/whisper-base")
346
  whisper_model = WhisperForConditionalGeneration.from_pretrained("openai/whisper-base")
347
  whisper_model.config.forced_decoder_ids = None
348
  logger.info("Whisper model loaded successfully")
349
 
350
+ if init_asr and PUNCTUATE_TEXT and punctuation_model is None:
351
  logger.info(f"Loading punctuation model ({PUNCTUATION_MODEL})...")
352
  punctuation_tokenizer = AutoTokenizer.from_pretrained(PUNCTUATION_MODEL)
353
  punctuation_model = AutoModelForTokenClassification.from_pretrained(PUNCTUATION_MODEL)
 
358
  logger.error(f"Error initializing models: {str(e)}")
359
  raise
360
 
361
+ def ensure_models_for_tts():
362
+ if kokoro_tts_mode is not None:
363
+ return
364
+ with model_init_lock:
365
+ if kokoro_tts_mode is None:
366
+ initialize_models(init_tts=True, init_asr=False)
367
+
368
+ def ensure_models_for_asr():
369
+ asr_ready = (ASR_ENGINE == "wav2vec2_onnx" and asr_session is not None) or (
370
+ ASR_ENGINE != "wav2vec2_onnx" and processor is not None and whisper_model is not None
371
+ )
372
+ punct_ready = (not PUNCTUATE_TEXT) or (punctuation_model is not None and punctuation_tokenizer is not None)
373
+ if asr_ready and punct_ready:
374
+ return
375
+ with model_init_lock:
376
+ asr_ready = (ASR_ENGINE == "wav2vec2_onnx" and asr_session is not None) or (
377
+ ASR_ENGINE != "wav2vec2_onnx" and processor is not None and whisper_model is not None
378
+ )
379
+ punct_ready = (not PUNCTUATE_TEXT) or (punctuation_model is not None and punctuation_tokenizer is not None)
380
+ if not (asr_ready and punct_ready):
381
+ initialize_models(init_tts=False, init_asr=True)
382
+
383
+ def _resolve_piper_model_and_config():
384
+ model_candidate = PIPER_MODEL_PATH.strip()
385
+ if not model_candidate:
386
+ model_candidate = os.path.join(model_path, "onnx", "model.onnx")
387
+
388
+ config_candidate = PIPER_CONFIG_PATH.strip()
389
+ if not config_candidate:
390
+ for cand in [
391
+ f"{model_candidate}.json",
392
+ os.path.join(os.path.dirname(model_candidate), "config.json"),
393
+ os.path.join(model_path, "config.json"),
394
+ ]:
395
+ if os.path.isfile(cand):
396
+ config_candidate = cand
397
+ break
398
+
399
+ missing_model = not os.path.isfile(model_candidate)
400
+ missing_config = not config_candidate or not os.path.isfile(config_candidate)
401
+
402
+ if (missing_model or missing_config) and PIPER_AUTO_DOWNLOAD:
403
+ logger.info(
404
+ f"Missing Piper assets (model_missing={missing_model}, config_missing={missing_config}). "
405
+ f"Downloading from {PIPER_REPO_ID}..."
406
+ )
407
+ allow_patterns = [PIPER_REPO_MODEL_FILE, PIPER_REPO_CONFIG_FILE]
408
+ try:
409
+ import inspect
410
+ sig = inspect.signature(snapshot_download)
411
+ if "local_dir" in sig.parameters:
412
+ repo_dir = snapshot_download(
413
+ PIPER_REPO_ID,
414
+ local_dir=PIPER_DOWNLOAD_DIR,
415
+ local_dir_use_symlinks=False,
416
+ allow_patterns=allow_patterns,
417
+ resume_download=True,
418
+ )
419
+ else:
420
+ repo_dir = snapshot_download(
421
+ PIPER_REPO_ID,
422
+ cache_dir=PIPER_DOWNLOAD_DIR,
423
+ allow_patterns=allow_patterns,
424
+ resume_download=True,
425
+ )
426
+ except Exception:
427
+ repo_dir = snapshot_download(
428
+ PIPER_REPO_ID,
429
+ cache_dir=PIPER_DOWNLOAD_DIR,
430
+ allow_patterns=allow_patterns,
431
+ resume_download=True,
432
+ )
433
+
434
+ src_model = None
435
+ src_config = None
436
+ for root, _, files in os.walk(repo_dir):
437
+ for f in files:
438
+ if f == PIPER_REPO_MODEL_FILE:
439
+ src_model = os.path.join(root, f)
440
+ elif f == PIPER_REPO_CONFIG_FILE:
441
+ src_config = os.path.join(root, f)
442
+
443
+ if missing_model:
444
+ if not src_model:
445
+ raise FileNotFoundError(
446
+ f"Failed to find {PIPER_REPO_MODEL_FILE} in downloaded repo {PIPER_REPO_ID}"
447
+ )
448
+ os.makedirs(os.path.dirname(model_candidate), exist_ok=True)
449
+ shutil.copyfile(src_model, model_candidate)
450
+ logger.info(f"Downloaded Piper model to {model_candidate}")
451
+
452
+ if not config_candidate:
453
+ config_candidate = f"{model_candidate}.json"
454
+
455
+ if not os.path.isfile(config_candidate):
456
+ if not src_config:
457
+ raise FileNotFoundError(
458
+ f"Failed to find {PIPER_REPO_CONFIG_FILE} in downloaded repo {PIPER_REPO_ID}"
459
+ )
460
+ os.makedirs(os.path.dirname(config_candidate), exist_ok=True)
461
+ shutil.copyfile(src_config, config_candidate)
462
+ logger.info(f"Downloaded Piper config to {config_candidate}")
463
+
464
+ if not os.path.isfile(model_candidate):
465
+ raise FileNotFoundError(
466
+ f"Piper model not found at {model_candidate}. "
467
+ f"Set PIPER_MODEL_PATH or enable PIPER_AUTO_DOWNLOAD from {PIPER_REPO_ID}."
468
+ )
469
+
470
+ if config_candidate and not os.path.isfile(config_candidate):
471
+ raise FileNotFoundError(
472
+ f"Piper config not found at {config_candidate}. "
473
+ "Set PIPER_CONFIG_PATH to the .onnx.json file (for HAL use hal.onnx.json)."
474
+ )
475
+ return model_candidate, config_candidate
476
+
477
+ if MODEL_INIT_MODE == "startup":
478
+ initialize_models(init_tts=True, init_asr=True)
479
+ if KOKORO_ONNX_MODE == "piper" and PIPER_PREFETCH_ON_STARTUP:
480
+ _resolve_piper_model_and_config()
481
+
482
+ def synthesize_with_piper(text, output_path):
483
+ model_file, config_file = _resolve_piper_model_and_config()
484
+
485
+ cmd = [PIPER_BIN, "--model", model_file, "--output_file", output_path]
486
+ if config_file:
487
+ cmd.extend(["--config", config_file])
488
+ if KOKORO_SPEAKER_ID >= 0:
489
+ cmd.extend(["--speaker", str(KOKORO_SPEAKER_ID)])
490
+
491
+ cmd.extend([
492
+ "--noise_scale", str(KOKORO_STTS2_NOISE_SCALE),
493
+ "--length_scale", str(KOKORO_STTS2_LENGTH_SCALE),
494
+ "--noise_w", str(KOKORO_STTS2_NOISE_W),
495
+ ])
496
+
497
+ try:
498
+ result = subprocess.run(
499
+ cmd,
500
+ input=text,
501
+ text=True,
502
+ stdout=subprocess.PIPE,
503
+ stderr=subprocess.PIPE,
504
+ check=False,
505
+ timeout=PIPER_TIMEOUT_SEC,
506
+ )
507
+ except FileNotFoundError as e:
508
+ raise RuntimeError(
509
+ f"Piper binary not found: {PIPER_BIN}. Install Piper or set PIPER_BIN."
510
+ ) from e
511
+ except subprocess.TimeoutExpired as e:
512
+ raise RuntimeError(
513
+ f"Piper synthesis timed out after {PIPER_TIMEOUT_SEC:.0f}s. "
514
+ f"Command: {' '.join(cmd)}"
515
+ ) from e
516
+
517
+ if result.returncode != 0:
518
+ raise RuntimeError(f"Piper synthesis failed: {result.stderr.strip() or result.stdout.strip()}")
519
+
520
+ if not os.path.isfile(output_path) or os.path.getsize(output_path) == 0:
521
+ raise RuntimeError("Piper did not produce an output wav file.")
522
 
523
  def restore_punctuation(text, max_words=120):
524
  if not PUNCTUATE_TEXT:
 
677
  @app.route('/generate_audio', methods=['POST'])
678
  def generate_audio():
679
  """Text-to-Speech (T2S) Endpoint"""
680
+ request_id = str(uuid.uuid4())[:8]
681
+ start_time = time.monotonic()
682
+ logger.info(f"[{request_id}] /generate_audio request received")
683
+ acquired = global_lock.acquire(timeout=REQUEST_LOCK_TIMEOUT_SEC)
684
+ if not acquired:
685
+ logger.warning(
686
+ f"[{request_id}] /generate_audio lock wait exceeded {REQUEST_LOCK_TIMEOUT_SEC:.0f}s"
687
+ )
688
+ return jsonify({
689
+ "status": "error",
690
+ "message": "Service busy: previous request still running",
691
+ }), 503
692
+ try:
693
+ logger.info(f"[{request_id}] /generate_audio lock acquired")
694
  try:
695
+ ensure_models_for_tts()
696
+ data = request.get_json(silent=True) or {}
697
+ text = data.get("text")
698
 
699
  validate_text_input(text)
700
 
701
+ # Use legacy preprocessing for Kokoro ONNX, but raw text for Piper.
702
+ text_for_tts = text if kokoro_tts_mode == "piper" else preprocess_all(text)
703
+ if kokoro_tts_mode == "piper":
704
+ cache_fingerprint = f"mode=piper|model={PIPER_MODEL_PATH}|config={PIPER_CONFIG_PATH}|speaker={KOKORO_SPEAKER_ID}"
705
+ elif kokoro_tts_mode == "legacy":
706
+ cache_fingerprint = f"mode=legacy|voice={voice_name}"
707
+ else:
708
+ cache_fingerprint = (
709
+ f"mode=stts2|speaker={KOKORO_SPEAKER_ID}|"
710
+ f"noise={KOKORO_STTS2_NOISE_SCALE}|len={KOKORO_STTS2_LENGTH_SCALE}|noisew={KOKORO_STTS2_NOISE_W}"
711
+ )
712
+ text_hash = hashlib.sha256(f"{cache_fingerprint}|{text_for_tts}".encode('utf-8')).hexdigest()
713
  filename = f"{text_hash}.wav"
714
  cached_file_path = os.path.join(SERVE_DIR, filename)
715
 
716
  # Cache hit
717
  if is_cached(cached_file_path):
718
+ logger.info(f"[{request_id}] Returning cached audio: {filename}")
719
  return jsonify({"status": "success", "filename": filename})
720
 
721
+ if kokoro_tts_mode == "piper":
722
+ synthesize_with_piper(text_for_tts, cached_file_path)
723
+ else:
724
+ # Tokenize
725
+ from kokoro import phonemize, tokenize # lazy import is fine
726
+ tokens = tokenize(phonemize(text_for_tts, 'a'))
727
+ if len(tokens) > KOKORO_MAX_PHONEME_TOKENS - 1:
728
+ logger.warning(f"Text too long; truncating to {KOKORO_MAX_PHONEME_TOKENS - 1} tokens.")
729
+ tokens = tokens[:KOKORO_MAX_PHONEME_TOKENS - 1]
730
+
731
+ if kokoro_tts_mode == "legacy":
732
+ tokens = [[0, *tokens, 0]]
733
+ ref_s = voice_style[len(tokens[0]) - 2] # (1,256)
734
+ ort_inputs = {
735
+ "input_ids": np.array(tokens, dtype=np.int64),
736
+ "style": ref_s,
737
+ "speed": np.ones(1, dtype=np.float32),
738
+ }
739
+ else:
740
+ token_ids = np.array([tokens], dtype=np.int64)
741
+ ort_inputs = {}
742
+ if "input" in kokoro_input_names:
743
+ ort_inputs["input"] = token_ids
744
+ elif "input_ids" in kokoro_input_names:
745
+ ort_inputs["input_ids"] = token_ids
746
+ else:
747
+ first_name = sess.get_inputs()[0].name
748
+ ort_inputs[first_name] = token_ids
749
+
750
+ if "input_lengths" in kokoro_input_names:
751
+ ort_inputs["input_lengths"] = np.array([token_ids.shape[1]], dtype=np.int64)
752
+ if "ids" in kokoro_input_names:
753
+ ort_inputs["ids"] = np.array([KOKORO_SPEAKER_ID], dtype=np.int64)
754
+ if "sid" in kokoro_input_names:
755
+ ort_inputs["sid"] = np.array([KOKORO_SPEAKER_ID], dtype=np.int64)
756
+ if "scales" in kokoro_input_names:
757
+ ort_inputs["scales"] = np.array(
758
+ [KOKORO_STTS2_NOISE_SCALE, KOKORO_STTS2_LENGTH_SCALE, KOKORO_STTS2_NOISE_W],
759
+ dtype=np.float32,
760
+ )
761
+ if "speed" in kokoro_input_names:
762
+ ort_inputs["speed"] = np.ones(1, dtype=np.float32)
763
+
764
+ audio = sess.run(None, ort_inputs)[0]
765
+
766
+ # Save
767
+ audio = np.squeeze(audio).astype(np.float32)
768
+ sf.write(cached_file_path, audio, 24000)
769
+
770
+ elapsed = time.monotonic() - start_time
771
+ logger.info(f"[{request_id}] Audio saved: {cached_file_path} ({elapsed:.2f}s)")
772
  return jsonify({"status": "success", "filename": filename})
773
  except Exception as e:
774
+ elapsed = time.monotonic() - start_time
775
+ logger.exception(f"[{request_id}] Error generating audio after {elapsed:.2f}s: {str(e)}")
776
  return jsonify({"status": "error", "message": str(e)}), 500
777
+ finally:
778
+ global_lock.release()
779
+ elapsed = time.monotonic() - start_time
780
+ logger.info(f"[{request_id}] /generate_audio completed ({elapsed:.2f}s)")
781
 
782
  # Speech-to-Text (S2T) Endpoint
 
 
 
 
 
 
783
  @app.route('/transcribe_audio', methods=['POST'])
784
  def transcribe_audio():
785
  """Speech-to-Text (S2T) Endpoint with automatic format conversion"""
786
+ request_id = str(uuid.uuid4())[:8]
787
+ start_time = time.monotonic()
788
+ logger.info(f"[{request_id}] /transcribe_audio request received")
789
+ acquired = global_lock.acquire(timeout=REQUEST_LOCK_TIMEOUT_SEC)
790
+ if not acquired:
791
+ logger.warning(
792
+ f"[{request_id}] /transcribe_audio lock wait exceeded {REQUEST_LOCK_TIMEOUT_SEC:.0f}s"
793
+ )
794
+ return jsonify({
795
+ "status": "error",
796
+ "message": "Service busy: previous request still running",
797
+ }), 503
798
+ input_audio_path = None
799
+ converted_audio_path = None
800
+ try:
801
+ logger.info(f"[{request_id}] /transcribe_audio lock acquired")
802
  try:
803
+ ensure_models_for_asr()
804
+ if 'file' not in request.files:
805
+ logger.warning(f"[{request_id}] No audio file part in request")
806
+ return jsonify({"status": "error", "message": "Missing file field 'file'"}), 400
807
  file = request.files['file']
808
+ validate_audio_file(file)
809
+ logger.info(
810
+ f"[{request_id}] STT input accepted: name={file.filename!r} "
811
+ f"content_type={file.content_type!r}"
812
+ )
813
 
814
  # Create temporary files for both input and output
815
  with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename).suffix) as input_temp:
816
  input_audio_path = input_temp.name
817
  file.save(input_audio_path)
818
+ input_size = os.path.getsize(input_audio_path)
819
+ logger.info(f"[{request_id}] Input audio saved to {input_audio_path} ({input_size} bytes)")
820
 
821
  # Create a temporary file for the converted WAV
822
  with tempfile.NamedTemporaryFile(delete=False, suffix='.wav') as output_temp:
823
  converted_audio_path = output_temp.name
824
 
825
  # Convert to WAV with ffmpeg (16kHz, mono)
826
+ logger.info(f"[{request_id}] FFmpeg conversion started (timeout={FFMPEG_TIMEOUT_SEC:.0f}s)")
827
  conversion_command = [
828
  'ffmpeg',
829
  '-y', # Force overwrite without prompting
 
834
  '-af', 'highpass=f=80,lowpass=f=7500,afftdn=nr=10:nf=-25,loudnorm=I=-16:TP=-1.5:LRA=11', # Audio cleanup filters
835
  converted_audio_path
836
  ]
837
+ try:
838
+ ffmpeg_start = time.monotonic()
839
+ result = subprocess.run(
840
+ conversion_command,
841
+ stdout=subprocess.PIPE,
842
+ stderr=subprocess.PIPE,
843
+ text=True,
844
+ timeout=FFMPEG_TIMEOUT_SEC,
845
+ )
846
+ ffmpeg_elapsed = time.monotonic() - ffmpeg_start
847
+ except subprocess.TimeoutExpired as e:
848
+ raise RuntimeError(
849
+ f"FFmpeg conversion timed out after {FFMPEG_TIMEOUT_SEC:.0f}s"
850
+ ) from e
851
+
852
  if result.returncode != 0:
853
  logger.error(f"FFmpeg conversion error: {result.stderr}")
854
  raise Exception(f"Audio conversion failed: {result.stderr}")
855
 
856
+ converted_size = os.path.getsize(converted_audio_path)
857
+ logger.info(
858
+ f"[{request_id}] FFmpeg conversion complete in {ffmpeg_elapsed:.2f}s "
859
+ f"-> {converted_audio_path} ({converted_size} bytes)"
860
+ )
861
 
862
  # Load and process the converted audio
863
+ logger.info(f"[{request_id}] Loading converted audio for ASR")
864
  audio_array, sampling_rate = librosa.load(converted_audio_path, sr=16000)
865
+ duration_sec = (len(audio_array) / sampling_rate) if sampling_rate else 0.0
866
+ logger.info(
867
+ f"[{request_id}] ASR input ready: samples={len(audio_array)} "
868
+ f"rate={sampling_rate} duration={duration_sec:.2f}s engine={ASR_ENGINE}"
869
+ )
870
 
871
  if ASR_ENGINE == "wav2vec2_onnx" and 'asr_session' in globals() and asr_session is not None:
872
+ asr_start = time.monotonic()
873
  # Prepare input for Wav2Vec2 ONNX: float32 PCM, shape (batch, samples)
874
  inputs = asr_processor(audio_array, sampling_rate=16000, return_tensors="np")
875
  # Some exports expect input as (batch, sequence); adjust key as needed
 
890
  # Collapse repeats and remove CTC blank (id 0 for many models; rely on processor)
891
  transcription = asr_processor.batch_decode(pred_ids)[0]
892
  transcription = transcription.strip()
893
+ asr_elapsed = time.monotonic() - asr_start
894
+ logger.info(
895
+ f"[{request_id}] ASR (Wav2Vec2 ONNX) complete in {asr_elapsed:.2f}s "
896
+ f"(chars={len(transcription)})"
897
+ )
898
  else:
899
+ asr_start = time.monotonic()
900
  # Whisper fallback
901
  input_features = processor(
902
  audio_array,
 
907
  logger.debug("Generating transcription (Whisper)...")
908
  predicted_ids = whisper_model.generate(input_features)
909
  transcription = processor.batch_decode(predicted_ids, skip_special_tokens=True)[0]
910
+ asr_elapsed = time.monotonic() - asr_start
911
+ logger.info(
912
+ f"[{request_id}] ASR (Whisper) complete in {asr_elapsed:.2f}s "
913
+ f"(chars={len(transcription)})"
914
+ )
915
 
916
  if PUNCTUATE_TEXT:
917
  try:
 
927
  except Exception as ne:
928
  logger.warning(f"Tech normalization failed: {ne}")
929
 
930
+ preview = transcription[:160].replace("\n", " ").strip()
931
+ if len(transcription) > 160:
932
+ preview += "..."
933
+ logger.info(f"[{request_id}] STT transcription preview: {preview}")
934
  return jsonify({"status": "success", "transcription": transcription})
935
  except Exception as e:
936
+ elapsed = time.monotonic() - start_time
937
+ logger.exception(f"[{request_id}] Error transcribing audio after {elapsed:.2f}s: {str(e)}")
938
  return jsonify({"status": "error", "message": str(e)}), 500
939
  finally:
940
  # Clean up temporary files
 
945
  logger.debug(f"Temporary file {path} removed")
946
  except Exception as e:
947
  logger.warning(f"Failed to remove temporary file {path}: {e}")
948
+ finally:
949
+ global_lock.release()
950
+ elapsed = time.monotonic() - start_time
951
+ logger.info(f"[{request_id}] /transcribe_audio completed ({elapsed:.2f}s)")
952
 
953
  @app.route('/files/<filename>', methods=['GET'])
954
  def serve_wav_file(filename):