Spaces:
Running
Running
Commit ·
aea6642
1
Parent(s): 94f247f
fixed token limit
Browse files
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
logger.info(f"ONNX session loaded successfully from {onnx_path}")
|
| 173 |
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
|
|
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 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 |
-
|
| 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 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
try:
|
| 413 |
-
|
| 414 |
-
data = request.
|
| 415 |
-
text = data
|
| 416 |
|
| 417 |
validate_text_input(text)
|
| 418 |
|
| 419 |
-
#
|
| 420 |
-
text = preprocess_all(text)
|
| 421 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
tokens =
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
return jsonify({"status": "success", "filename": filename})
|
| 454 |
except Exception as e:
|
| 455 |
-
|
|
|
|
| 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 |
-
|
| 469 |
-
|
| 470 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
try:
|
| 472 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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.
|
| 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 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
|
| 510 |
# Load and process the converted audio
|
| 511 |
-
logger.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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):
|