import io import json import os import re import subprocess import sys import tempfile import traceback from functools import lru_cache from pathlib import Path from typing import Literal import numpy as np import soundfile as sf import torch from fastapi import FastAPI, HTTPException from fastapi.responses import Response from huggingface_hub import hf_hub_download from pydantic import BaseModel, Field from transformers import AutoModelForCausalLM, AutoTokenizer APP_VERSION = '2.0.0' DEFAULT_SAMPLE_RATE = 22050 LLM_MODEL_ID = os.getenv('PODCAST_LLM_MODEL', 'Qwen/Qwen2.5-0.5B-Instruct') LLM_FALLBACK_MODEL_ID = os.getenv('PODCAST_LLM_FALLBACK_MODEL', 'Vikhrmodels/Vikhr-Qwen-2.5-0.5b-Instruct') LLM_DEVICE_MODE = os.getenv('PODCAST_LLM_DEVICE', 'auto').lower() LLM_MAX_FACTS = int(os.getenv('PODCAST_LLM_MAX_FACTS', '4')) LLM_MAX_INPUT_CHARS = int(os.getenv('PODCAST_LLM_MAX_INPUT_CHARS', '7000')) LLM_MAX_NEW_TOKENS = int(os.getenv('PODCAST_LLM_MAX_NEW_TOKENS', '420')) PODCAST_LLM_ENABLED = os.getenv('PODCAST_LLM_ENABLED', 'false').lower() in {'1', 'true', 'yes', 'on'} TTS_ENGINE = os.getenv('TTS_ENGINE', 'piper').lower() TTS_MAX_REQUEST_CHARS = int(os.getenv('TTS_MAX_REQUEST_CHARS', os.getenv('TTS_MAX_TEXT_CHARS', '1800'))) TTS_CHUNK_MAX_CHARS = int(os.getenv('TTS_CHUNK_MAX_CHARS', '420')) TTS_RUSSIAN_NORMALIZATION_ENABLED = os.getenv('TTS_RUSSIAN_NORMALIZATION_ENABLED', 'true').lower() in {'1', 'true', 'yes', 'on'} PIPER_VOICE_REPO = os.getenv('PIPER_VOICE_REPO', 'rhasspy/piper-voices') PIPER_DEFAULT_VOICE = os.getenv('PIPER_DEFAULT_VOICE', 'ru_RU-irina-medium') PIPER_FALLBACK_VOICE = 'ru_RU-dmitri-medium' PIPER_VOICES = { 'ru_RU-irina-medium': 'ru/ru_RU/irina/medium', 'ru_RU-dmitri-medium': 'ru/ru_RU/dmitri/medium', } if PIPER_DEFAULT_VOICE not in PIPER_VOICES: PIPER_DEFAULT_VOICE = PIPER_FALLBACK_VOICE app = FastAPI(title='Bike Podcast Space', version=APP_VERSION) LLM_LOADED_MODEL_ID: str | None = None LAST_SCRIPT_ERROR = '' LAST_SCRIPT_ERROR_TYPE = '' LAST_SCRIPT_MODEL = '' LAST_SCRIPT_PROVIDER = '' class RouteSummary(BaseModel): distance_m: float | None = None duration_sec: float | None = None average_speed_kmh: float | None = None class PodcastFact(BaseModel): id: str = '' index: int = 0 name: str = '' type: str = '' subtype: str = '' address: str | None = None description: str | None = None wikipedia_description: str | None = None wikipedia_extract: str | None = None research_summary: str | None = None research_source: str | None = None website: str | None = None source_url: str | None = None class PodcastScriptRequest(BaseModel): route: RouteSummary = Field(default_factory=RouteSummary) facts: list[PodcastFact] = Field(default_factory=list) language: str = 'ru' style: str = 'friendly' class PodcastScriptSegment(BaseModel): role: Literal['host', 'guide'] title: str = '' text: str = Field(min_length=1) source_ids: list[str] = Field(default_factory=list) class PodcastScriptResponse(BaseModel): segments: list[PodcastScriptSegment] provider: str model: str class TtsRequest(BaseModel): text: str = Field(min_length=1) role: str = 'narrator' voice: str = '' speed: float = Field(default=1.0, ge=0.7, le=1.3) format: str = 'wav' language: str = 'ru' def select_device() -> str: if LLM_DEVICE_MODE == 'cpu': return 'cpu' if LLM_DEVICE_MODE == 'cuda': return 'cuda' return 'cuda' if torch.cuda.is_available() else 'cpu' def clean_text(value: str | None, limit: int = 900) -> str: if not value: return '' text = re.sub(r'[\x00-\x1f]+', ' ', str(value)) text = re.sub(r'\s+', ' ', text).strip() if len(text) <= limit: return text shortened = text[:limit] last_sentence = max(shortened.rfind('. '), shortened.rfind('! '), shortened.rfind('? ')) if last_sentence > 180: return shortened[:last_sentence + 1].strip() return shortened.rsplit(' ', 1)[0].strip() def clean_full_text(value: str | None) -> str: if not value: return '' text = re.sub(r'[\x00-\x1f]+', ' ', str(value)) return re.sub(r'\s+', ' ', text).strip() def russian_number_unit(raw_number: str, forms: tuple[str, str, str]) -> str: spoken_number = raw_number.replace('.', ',') if ',' in spoken_number: return f'{spoken_number} {forms[1]}' value = int(spoken_number) remainder_100 = value % 100 remainder_10 = value % 10 if 11 <= remainder_100 <= 14: form = forms[2] elif remainder_10 == 1: form = forms[0] elif 2 <= remainder_10 <= 4: form = forms[1] else: form = forms[2] return f'{spoken_number} {form}' def normalize_spoken_russian(text: str) -> str: if not TTS_RUSSIAN_NORMALIZATION_ENABLED: return text normalized = text address_rules = [ (r'\bв\s+г\.\s*(?=[А-ЯЁ])', 'в городе '), (r'\bиз\s+г\.\s*(?=[А-ЯЁ])', 'из города '), (r'\bпо\s+г\.\s*(?=[А-ЯЁ])', 'по городу '), (r'\bг\.\s*(?=[А-ЯЁ])', 'город '), (r'\bна\s+ул\.\s*', 'на улице '), (r'\bпо\s+ул\.\s*', 'по улице '), (r'\bс\s+ул\.\s*', 'с улицы '), (r'\bул\.\s*', 'улица '), (r'\bна\s+(?:пр-т|просп\.)\s*', 'на проспекте '), (r'\bпо\s+(?:пр-т|просп\.)\s*', 'по проспекту '), (r'\b(?:пр-т|просп\.)\s*', 'проспект '), (r'\bв\s+пер\.\s*', 'в переулке '), (r'\bпо\s+пер\.\s*', 'по переулку '), (r'\bпер\.\s*', 'переулок '), (r'\bна\s+пл\.\s*', 'на площади '), (r'\bпл\.\s*', 'площадь '), (r'\bд\.\s*(?=\d)', 'дом '), (r'\bкорп\.\s*(?=\d)', 'корпус '), (r'\bстр\.\s*(?=\d)', 'строение '), ] normalized = re.sub(r'\bв\s+(\d{3,4})\s*г\.\s*', r'в \1 году ', normalized) normalized = re.sub(r'\b(\d{3,4})\s*г\.\s*', r'\1 год ', normalized) for pattern, replacement in address_rules: normalized = re.sub(pattern, replacement, normalized, flags=re.IGNORECASE) unit_rules = [ (r'(\d+(?:[.,]\d+)?)\s*км\s*/\s*ч\b', ('километр в час', 'километра в час', 'километров в час')), (r'(\d+(?:[.,]\d+)?)\s*км\b', ('километр', 'километра', 'километров')), (r'(\d+(?:[.,]\d+)?)\s*мин\b', ('минута', 'минуты', 'минут')), (r'(\d+(?:[.,]\d+)?)\s*ч\b', ('час', 'часа', 'часов')), (r'(\d+(?:[.,]\d+)?)\s*м\b', ('метр', 'метра', 'метров')), ] for pattern, forms in unit_rules: normalized = re.sub(pattern, lambda match: russian_number_unit(match.group(1), forms), normalized) return re.sub(r'\s+', ' ', normalized).strip() def normalize_tts_text(text: str) -> str: normalized = clean_full_text(text) if not normalized: raise HTTPException(status_code=400, detail='Text is required.') if len(normalized) > TTS_MAX_REQUEST_CHARS: raise HTTPException(status_code=400, detail='TTS text exceeds maximum request length.') return normalize_spoken_russian(normalized) def split_tts_chunks(text: str, max_chars: int = TTS_CHUNK_MAX_CHARS) -> list[str]: if max_chars < 20: raise ValueError('TTS chunk size must be at least 20 characters.') chunks: list[str] = [] current = '' phrases = re.findall(r'.+?(?:[.!?;:]+(?=\s|$)|$)', text) for phrase in phrases: phrase = phrase.strip() if not phrase: continue if len(phrase) > max_chars: words = phrase.split() pieces: list[str] = [] part = '' for word in words: if len(word) > max_chars: if part: pieces.append(part) part = '' # An unusually long token is preserved rather than read as broken fragments. pieces.append(word) elif not part or len(part) + len(word) + 1 <= max_chars: part = f'{part} {word}'.strip() else: pieces.append(part) part = word if part: pieces.append(part) else: pieces = [phrase] for piece in pieces: if not current: current = piece elif len(current) + len(piece) + 1 <= max_chars: current = f'{current} {piece}' else: chunks.append(current) current = piece if current: chunks.append(current) return chunks or [text] def resolve_piper_voice(voice: str, role: str) -> str: requested = clean_text(voice, 80) if requested in PIPER_VOICES: return requested # Older PHP configs sent the semantic role as voice; keep that contract safe. if not requested or requested in {'host', 'guide', 'narrator'}: return PIPER_DEFAULT_VOICE if PIPER_DEFAULT_VOICE in PIPER_VOICES else PIPER_FALLBACK_VOICE raise HTTPException(status_code=400, detail='Unsupported Piper voice.') def facts_payload(facts: list[PodcastFact]) -> list[dict]: payload = [] for fact in facts[:max(1, LLM_MAX_FACTS)]: name = clean_text(fact.name, 140) if not name: continue payload.append({ 'id': clean_text(fact.id, 80) or f'fact-{len(payload) + 1}', 'index': fact.index, 'name': name, 'type': clean_text(fact.type, 80), 'subtype': clean_text(fact.subtype, 80), 'address': clean_text(fact.address, 160), 'description': clean_text(fact.description, 500), 'wikipedia_description': clean_text(fact.wikipedia_description, 220), 'wikipedia_extract': clean_text(fact.wikipedia_extract, 900), 'research_summary': clean_text(fact.research_summary, 700), 'research_source': clean_text(fact.research_source, 180), 'website': clean_text(fact.website, 180), 'source_url': clean_text(fact.source_url, 180), }) return payload def build_prompt(request: PodcastScriptRequest) -> str: facts = facts_payload(request.facts) if not facts: raise HTTPException(status_code=400, detail='At least one fact with a name is required.') source_json = json.dumps({ 'route': request.route.model_dump(), 'facts': facts, }, ensure_ascii=False) if len(source_json) > LLM_MAX_INPUT_CHARS: source_json = source_json[:LLM_MAX_INPUT_CHARS] return ( 'Ты сценарист короткого аудиоподкаста для велосипедного маршрута.\n' 'Пиши только на русском языке.\n' 'Используй только факты из блока SOURCE. Не добавляй даты, имена, легенды, оценки и историю, которых нет в SOURCE.\n' 'Если данных мало, говори честно и нейтрально: что это точка маршрута, что можно заметить по названию, типу или адресу.\n' 'Верни только валидный JSON без markdown и пояснений.\n' 'Формат ответа: {"segments":[{"role":"host","title":"...","text":"...","source_ids":["..."]},{"role":"guide","title":"...","text":"...","source_ids":["..."]}]}.\n' 'Сделай 2 реплики на каждую точку: короткий вопрос/подводка ведущего и короткий ответ гида.\n' 'Каждая реплика: 1-2 предложения, до 230 символов.\n' f'SOURCE:\n{source_json}\n' 'JSON:' ) def extract_json_object(text: str) -> dict: stripped = text.strip() if stripped.startswith('```'): stripped = re.sub(r'^```(?:json)?\s*', '', stripped) stripped = re.sub(r'\s*```$', '', stripped) try: return json.loads(stripped) except json.JSONDecodeError: start = stripped.find('{') end = stripped.rfind('}') if start < 0 or end <= start: raise return json.loads(stripped[start:end + 1]) def source_text_for_validation(facts: list[dict]) -> str: values = [] for fact in facts: for key in [ 'name', 'type', 'subtype', 'address', 'description', 'wikipedia_description', 'wikipedia_extract', 'research_summary', 'research_source', 'website', 'source_url', ]: value = clean_text(fact.get(key), 1200) if value: values.append(value) return ' '.join(values).lower() def source_allowed_capitalized_words(facts: list[dict]) -> set[str]: source = source_text_for_validation(facts) return { word.lower() for word in re.findall(r'\b[А-ЯЁA-Z][А-ЯЁA-Zа-яёa-zA-Z-]{2,}\b', source) } def generated_unknown_capitalized_words(text: str, allowed_words: set[str]) -> list[str]: common_words = { 'это', 'здесь', 'точка', 'маршрут', 'ведущий', 'гид', 'если', 'можно', 'название', 'тип', 'адрес', 'путь', 'по', 'на', 'для', 'вот', 'коротко', 'сегодня', 'рядом', 'велосипеде', 'велосипедный', } unknown = [] for match in re.finditer(r'\b[А-ЯЁA-Z][А-ЯЁA-Zа-яёa-zA-Z-]{2,}\b', text): word = match.group(0).lower() if word in allowed_words or word in common_words: continue unknown.append(match.group(0)) return unknown def build_template_script_response(request: PodcastScriptRequest, reason: str = '') -> PodcastScriptResponse: facts = facts_payload(request.facts) if not facts: raise HTTPException(status_code=400, detail='At least one fact with a name is required.') route_distance = request.route.distance_m route_text = '' if route_distance and route_distance > 0: route_text = f' Маршрут получился примерно {round(route_distance / 1000, 1)} км.' segments = [] for fact in facts[:max(1, LLM_MAX_FACTS)]: source_id = fact['id'] name = fact['name'] address = fact.get('address') or '' point_type = fact.get('subtype') or fact.get('type') or '' description = ( fact.get('wikipedia_description') or fact.get('research_summary') or fact.get('description') or fact.get('wikipedia_extract') or '' ) context_parts = [] if point_type: context_parts.append(f'тип: {point_type}') if address: context_parts.append(f'адрес: {address}') context = ', '.join(context_parts) host_text = f'Точка {fact.get("index") or len(segments) + 1}: {name}. Что здесь стоит заметить по данным маршрута?' if context: guide_text = f'{name}: {context}.' else: guide_text = f'{name} отмечена как точка этого маршрута.' if description: guide_text = f'{guide_text} {clean_text(description, 180)}' guide_text = f'{guide_text}{route_text}'.strip() segments.append(PodcastScriptSegment( role='host', title=name, text=clean_text(host_text, 240), source_ids=[source_id], )) segments.append(PodcastScriptSegment( role='guide', title=name, text=clean_text(guide_text, 260), source_ids=[source_id], )) if reason: print(f'[podcast-script] using template fallback: {reason}', file=sys.stderr) return PodcastScriptResponse(segments=segments, provider='template-fallback', model='route-facts-template') def configured_llm_model_ids() -> list[str]: model_ids = [] for model_id in [LLM_MODEL_ID, LLM_FALLBACK_MODEL_ID]: if model_id and model_id not in model_ids: model_ids.append(model_id) return model_ids @lru_cache(maxsize=2) def get_llm(model_id: str): global LLM_LOADED_MODEL_ID device = select_device() tokenizer = AutoTokenizer.from_pretrained(model_id) model = AutoModelForCausalLM.from_pretrained( model_id, torch_dtype=torch.float16 if device == 'cuda' else torch.float32, ) model.to(device) model.eval() LLM_LOADED_MODEL_ID = model_id return tokenizer, model, model_id def generate_script_text(prompt: str, model_id: str) -> tuple[str, str]: tokenizer, model, model_id = get_llm(model_id) device = select_device() rendered_prompt = prompt if getattr(tokenizer, 'chat_template', None): try: messages = [ {'role': 'system', 'content': 'Ты строго следуешь фактам и возвращаешь только JSON.'}, {'role': 'user', 'content': prompt}, ] rendered_prompt = tokenizer.apply_chat_template( messages, add_generation_prompt=True, tokenize=False, ) except Exception as exc: print( f'[podcast-script] {model_id} chat template failed, using plain prompt: {exc.__class__.__name__}: {exc}', file=sys.stderr, ) rendered_prompt = prompt inputs = tokenizer( rendered_prompt, return_tensors='pt', truncation=True, max_length=2048, ).to(device) eos_token_id = tokenizer.eos_token_id pad_token_id = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else eos_token_id with torch.inference_mode(): output_ids = model.generate( **inputs, max_new_tokens=LLM_MAX_NEW_TOKENS, do_sample=False, repetition_penalty=1.08, pad_token_id=pad_token_id, eos_token_id=eos_token_id, ) prompt_len = inputs['input_ids'].shape[-1] generated_ids = output_ids[0][prompt_len:] return tokenizer.decode(generated_ids, skip_special_tokens=True), model_id def describe_error(error: Exception) -> tuple[str, str]: error_type = error.__class__.__name__ message = str(error).strip() if isinstance(error, HTTPException): message = str(error.detail).strip() if not message: formatted = traceback.format_exception(error.__class__, error, error.__traceback__) tail = ''.join(formatted[-4:]).strip() message = tail or '' return error_type, message[:700] def remember_script_error(model_id: str, error: Exception) -> tuple[str, str]: global LAST_SCRIPT_ERROR global LAST_SCRIPT_ERROR_TYPE global LAST_SCRIPT_MODEL error_type, message = describe_error(error) LAST_SCRIPT_MODEL = model_id LAST_SCRIPT_ERROR_TYPE = error_type LAST_SCRIPT_ERROR = message print(f'[podcast-script] {model_id} failed with {error_type}: {message}', file=sys.stderr) return error_type, message def clear_script_error(model_id: str, provider: str) -> None: global LAST_SCRIPT_ERROR global LAST_SCRIPT_ERROR_TYPE global LAST_SCRIPT_MODEL global LAST_SCRIPT_PROVIDER LAST_SCRIPT_MODEL = model_id LAST_SCRIPT_PROVIDER = provider LAST_SCRIPT_ERROR_TYPE = '' LAST_SCRIPT_ERROR = '' def mark_script_provider(model_id: str, provider: str) -> None: global LAST_SCRIPT_MODEL global LAST_SCRIPT_PROVIDER LAST_SCRIPT_MODEL = model_id LAST_SCRIPT_PROVIDER = provider def generate_script_response(request: PodcastScriptRequest, prompt: str, source_ids: set[str]) -> PodcastScriptResponse: errors = [] facts = facts_payload(request.facts) allowed_words = source_allowed_capitalized_words(facts) for model_id in configured_llm_model_ids(): try: generated, used_model_id = generate_script_text(prompt, model_id) raw = extract_json_object(generated) response = validate_script(raw, source_ids, used_model_id, allowed_words) clear_script_error(used_model_id, 'local-transformers') return response except Exception as exc: error_type, message = remember_script_error(model_id, exc) errors.append(f'{model_id}: {error_type}: {message}') continue detail = '; '.join(errors) if errors else 'No podcast LLM models configured.' response = build_template_script_response(request, detail) mark_script_provider(response.model, response.provider) return response def validate_script(raw: dict, source_ids: set[str], model_id: str, allowed_words: set[str]) -> PodcastScriptResponse: segments = raw.get('segments') if not isinstance(segments, list) or not segments: raise HTTPException(status_code=502, detail='LLM returned no podcast segments.') validated = [] for item in segments[:10]: if not isinstance(item, dict): continue role = item.get('role') if role not in ('host', 'guide'): continue text = clean_text(item.get('text'), 260) if not text: continue title = clean_text(item.get('title'), 90) unknown_words = generated_unknown_capitalized_words(f'{title} {text}', allowed_words) if unknown_words: raise HTTPException( status_code=502, detail=f'LLM introduced facts outside source: {", ".join(unknown_words[:8])}.', ) item_source_ids = [ clean_text(source_id, 80) for source_id in item.get('source_ids', []) if clean_text(source_id, 80) in source_ids ] if not item_source_ids: raise HTTPException(status_code=502, detail='LLM returned segment without valid source_ids.') validated.append(PodcastScriptSegment( role=role, title=title, text=text, source_ids=item_source_ids, )) if not validated: raise HTTPException(status_code=502, detail='LLM returned invalid podcast segments.') return PodcastScriptResponse(segments=validated, provider='local-transformers', model=model_id) @lru_cache(maxsize=2) def piper_paths(voice_name: str) -> tuple[str, str]: voice_base = PIPER_VOICES[voice_name] model_file = f'{voice_base}/{voice_name}.onnx' config_file = f'{voice_base}/{voice_name}.onnx.json' model_path = hf_hub_download(PIPER_VOICE_REPO, model_file) config_path = hf_hub_download(PIPER_VOICE_REPO, config_file) return model_path, config_path def synthesize_piper_chunk(text: str, speed: float, voice_name: str) -> tuple[np.ndarray, int]: model_path, config_path = piper_paths(voice_name) with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as wav_file: wav_path = wav_file.name length_scale = max(0.75, min(1.45, 1 / max(0.7, min(speed, 1.3)))) common_args = [ '--model', model_path, '--config', config_path, '--output_file', wav_path, '--length_scale', str(length_scale), ] commands = [ [sys.executable, '-m', 'piper', *common_args], ['piper', *common_args], ] try: last_error = '' for command in commands: try: subprocess.run( command, input=text, text=True, capture_output=True, check=True, timeout=45, ) break except FileNotFoundError as exc: last_error = str(exc) continue except subprocess.CalledProcessError as exc: last_error = (exc.stderr or exc.stdout or str(exc)).strip() if 'No module named piper' in last_error: continue raise else: raise HTTPException(status_code=502, detail=f'Piper TTS failed: {last_error[:300]}') audio, sample_rate = sf.read(wav_path, dtype='float32') return np.asarray(audio), int(sample_rate or DEFAULT_SAMPLE_RATE) except subprocess.TimeoutExpired as exc: raise HTTPException(status_code=504, detail='Piper TTS timed out.') from exc except subprocess.CalledProcessError as exc: stderr = (exc.stderr or '').strip() raise HTTPException(status_code=502, detail=f'Piper TTS failed: {stderr[:300]}') from exc finally: try: Path(wav_path).unlink(missing_ok=True) except Exception: pass def synthesize_with_piper(text: str, speed: float, voice_name: str) -> tuple[np.ndarray, int]: chunks = split_tts_chunks(text, TTS_CHUNK_MAX_CHARS) rendered_chunks: list[np.ndarray] = [] sample_rate = DEFAULT_SAMPLE_RATE for index, chunk in enumerate(chunks): chunk_audio, chunk_sample_rate = synthesize_piper_chunk(chunk, speed, voice_name) if index == 0: sample_rate = chunk_sample_rate elif chunk_sample_rate != sample_rate: raise HTTPException(status_code=502, detail='Piper returned mismatched audio sample rates.') rendered_chunks.append(np.asarray(chunk_audio, dtype='float32')) if index < len(chunks) - 1: rendered_chunks.append(np.zeros(max(1, int(sample_rate * 0.06)), dtype='float32')) return np.concatenate(rendered_chunks), sample_rate def write_audio(audio: np.ndarray, sample_rate: int, requested_format: str) -> tuple[bytes, str]: buffer = io.BytesIO() requested_format = requested_format.lower() if requested_format == 'ogg': target_format = 'OGG' content_type = 'audio/ogg' else: target_format = 'WAV' content_type = 'audio/wav' try: sf.write(buffer, audio, sample_rate, format=target_format) except Exception: buffer = io.BytesIO() sf.write(buffer, audio, sample_rate, format='WAV') content_type = 'audio/wav' return buffer.getvalue(), content_type @app.get('/health') def health(): return { 'status': 'ok', 'version': APP_VERSION, 'llm_model': LLM_MODEL_ID, 'llm_fallback_model': LLM_FALLBACK_MODEL_ID, 'llm_device': select_device(), 'podcast_llm_enabled': PODCAST_LLM_ENABLED, 'tts_engine': TTS_ENGINE, 'tts_voice': PIPER_DEFAULT_VOICE if TTS_ENGINE == 'piper' else 'disabled', 'tts_russian_normalization_enabled': TTS_RUSSIAN_NORMALIZATION_ENABLED, } @app.get('/diagnostics') def diagnostics(): return { 'status': 'ok', 'version': APP_VERSION, 'torch_cuda_available': torch.cuda.is_available(), 'llm_model': LLM_MODEL_ID, 'llm_fallback_model': LLM_FALLBACK_MODEL_ID, 'llm_loaded_model': LLM_LOADED_MODEL_ID, 'llm_device': select_device(), 'podcast_llm_enabled': PODCAST_LLM_ENABLED, 'llm_max_facts': LLM_MAX_FACTS, 'llm_max_new_tokens': LLM_MAX_NEW_TOKENS, 'last_script_model': LAST_SCRIPT_MODEL, 'last_script_provider': LAST_SCRIPT_PROVIDER, 'last_script_error': LAST_SCRIPT_ERROR, 'last_script_error_type': LAST_SCRIPT_ERROR_TYPE, 'tts_engine': TTS_ENGINE, 'tts_max_request_chars': TTS_MAX_REQUEST_CHARS, 'tts_chunk_max_chars': TTS_CHUNK_MAX_CHARS, 'tts_russian_normalization_enabled': TTS_RUSSIAN_NORMALIZATION_ENABLED, 'piper_voice_repo': PIPER_VOICE_REPO, 'piper_default_voice': PIPER_DEFAULT_VOICE, 'piper_allowed_voices': list(PIPER_VOICES.keys()), } @app.post('/podcast-script', response_model=PodcastScriptResponse) def podcast_script(request: PodcastScriptRequest): if not PODCAST_LLM_ENABLED: response = build_template_script_response( request, 'LLM disabled for responsive CPU Basic runtime.', ) mark_script_provider(response.model, response.provider) return response prompt = build_prompt(request) source_ids = {item['id'] for item in facts_payload(request.facts)} return generate_script_response(request, prompt, source_ids) @app.post('/tts') def tts(request: TtsRequest): text = normalize_tts_text(request.text) if TTS_ENGINE != 'piper': raise HTTPException(status_code=503, detail='TTS engine is disabled.') voice_name = resolve_piper_voice(request.voice, request.role) audio, sample_rate = synthesize_with_piper(text, request.speed, voice_name) bytes_payload, content_type = write_audio(audio, sample_rate, request.format) return Response( content=bytes_payload, media_type=content_type, headers={ 'Cache-Control': 'no-store', 'X-TTS-Provider': 'piper', 'X-TTS-Voice': voice_name, }, ) @app.get('/tts-test') def tts_test(): request = TtsRequest( text='Привет. Это тест аудиогида ВелоСёрч.', role='guide', format='wav', language='ru', ) return tts(request)