from __future__ import annotations import argparse import json import os from pathlib import Path import shutil import sqlite3 import sys import tempfile from typing import Any ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") from services.ai_voice_runtime_service.audiosocket import pcm16le_to_wav_bytes, resample_pcm16le from services.ai_voice_runtime_service.providers.tts import build_tts_provider def _default_provider_name() -> str: return os.getenv("AI_VOICE_TTS_PROVIDER", "yandex").strip() or "yandex" def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Bulk-materialize IVR prompt WAV files from a flow JSON document or IVR SQLite DB.", ) source = parser.add_mutually_exclusive_group(required=True) source.add_argument("--flow-json-file", help="Path to a flow JSON document.") source.add_argument("--sqlite-db", help="Path to SQLite DB containing ivr_flows.") parser.add_argument("--flow-id", default=None, help="Optional specific flow_id when reading from SQLite.") parser.add_argument("--output-dir", required=True, help="Directory where .wav files will be written.") parser.add_argument("--preview-dir", default=None, help="Optional directory to mirror generated WAV files into.") parser.add_argument( "--provider", default=_default_provider_name(), help="TTS provider name. Defaults to AI_VOICE_TTS_PROVIDER or yandex.", ) parser.add_argument("--sample-rate", type=int, default=8000, help="Target WAV sample rate. Defaults to 8000.") parser.add_argument("--default-language", default="ru", help="Fallback language for ambiguous prompts. Defaults to ru.") parser.add_argument( "--language-override", action="append", default=[], help="Explicit language override in prompt_audio_key=language form. May be repeated.", ) parser.add_argument("--dry-run", action="store_true", help="Print planned prompt files without generating audio.") return parser.parse_args() def _parse_language_overrides(raw_overrides: list[str]) -> dict[str, str]: overrides: dict[str, str] = {} for raw in raw_overrides: item = str(raw or "").strip() if not item: continue key, sep, value = item.partition("=") if not sep or not key.strip() or not value.strip(): raise SystemExit(f"Invalid --language-override value: {raw!r}") overrides[key.strip()] = value.strip() return overrides def _load_flow_document(args: argparse.Namespace) -> dict[str, Any]: if args.flow_json_file: return json.loads(Path(args.flow_json_file).read_text(encoding="utf-8")) conn = sqlite3.connect(str(args.sqlite_db)) try: if str(args.flow_id or "").strip(): row = conn.execute( "select flow_json from ivr_flows where flow_id = ? order by id desc limit 1", (args.flow_id.strip(),), ).fetchone() else: row = conn.execute( "select flow_json from ivr_flows where is_active = 1 order by id desc limit 1" ).fetchone() finally: conn.close() if row is None or not str(row[0] or "").strip(): raise SystemExit("Unable to load IVR flow JSON") return json.loads(row[0]) def _node_identity(node: dict[str, Any]) -> str: return str(node.get("node_id") or node.get("id") or "").strip() def _infer_language(node: dict[str, Any], *, prompt_key: str, default_language: str) -> str: candidates = [ prompt_key, _node_identity(node), str(node.get("resolved_queue_code") or "").strip(), str(node.get("outcome_code") or "").strip(), ] for candidate in candidates: lowered = candidate.lower() if any(token in lowered for token in ("_kz", "-kz", "_kk", "-kk", "kz_", "kk_")): return "kz" if any(token in lowered for token in ("_ru", "-ru", "ru_")): return "ru" text = str(node.get("prompt_text") or "").strip().lower() has_kazakh_letters = any(char in text for char in "әіңғүұқөһ") has_russian_wording = any(token in text for token in ("здравствуйте", "добро пожаловать", "русского", "службы поддержки", "отдела продаж")) if has_kazakh_letters and not has_russian_wording: return "kz" return str(default_language or "ru").strip() or "ru" def _collect_prompts( flow_document: dict[str, Any], *, default_language: str, language_overrides: dict[str, str], ) -> list[dict[str, str]]: prompts: list[dict[str, str]] = [] seen: dict[str, str] = {} def _append_prompt(*, node: dict[str, Any], prompt_key: str, prompt_text: str, language: str | None = None) -> None: normalized_key = str(prompt_key or "").strip() normalized_text = str(prompt_text or "").strip() if not normalized_key or not normalized_text: return previous_text = seen.get(normalized_key) if previous_text is not None and previous_text != normalized_text: raise SystemExit(f"Prompt key {normalized_key!r} is reused with different texts") seen[normalized_key] = normalized_text prompts.append( { "prompt_audio_key": normalized_key, "prompt_text": normalized_text, "language": language_overrides.get( normalized_key, str(language or "").strip() or _infer_language(node, prompt_key=normalized_key, default_language=default_language), ), "node_id": _node_identity(node), } ) for raw_node in flow_document.get("nodes", []): node = raw_node if isinstance(raw_node, dict) else None if node is None: continue prompt_sequence = node.get("prompt_sequence") if isinstance(prompt_sequence, list): for prompt in prompt_sequence: if not isinstance(prompt, dict): continue _append_prompt( node=node, prompt_key=str(prompt.get("prompt_audio_key") or ""), prompt_text=str(prompt.get("prompt_text") or ""), language=str(prompt.get("language") or "").strip() or None, ) _append_prompt( node=node, prompt_key=str(node.get("prompt_audio_key") or ""), prompt_text=str(node.get("prompt_text") or ""), ) return prompts def _write_atomic(target: Path, payload: bytes) -> None: target.parent.mkdir(parents=True, exist_ok=True) temp_path: str | None = None try: with tempfile.NamedTemporaryFile(dir=target.parent, delete=False, suffix=".tmp") as handle: handle.write(payload) temp_path = handle.name Path(temp_path).replace(target) target.chmod(0o644) finally: if temp_path: try: Path(temp_path).unlink(missing_ok=True) except OSError: pass def _materialize_prompt( *, provider_name: str, prompt_text: str, language: str, sample_rate: int, output_path: Path, ) -> None: provider = build_tts_provider(provider_name) synthesis = provider.synthesize(prompt_text, language=language) pcm_bytes = resample_pcm16le( synthesis.audio_bytes, input_rate_hz=synthesis.sample_rate_hz, output_rate_hz=max(sample_rate, 1), ) wav_bytes = pcm16le_to_wav_bytes( pcm_bytes, sample_rate_hz=max(sample_rate, 1), ) _write_atomic(output_path, wav_bytes) def main() -> int: args = _parse_args() flow_document = _load_flow_document(args) language_overrides = _parse_language_overrides(args.language_override) prompts = _collect_prompts( flow_document, default_language=args.default_language, language_overrides=language_overrides, ) output_dir = Path(args.output_dir).expanduser() preview_dir = Path(args.preview_dir).expanduser() if args.preview_dir else None output_dir.mkdir(parents=True, exist_ok=True) if preview_dir is not None: preview_dir.mkdir(parents=True, exist_ok=True) generated: list[dict[str, str]] = [] for prompt in prompts: output_path = output_dir / f"{prompt['prompt_audio_key']}.wav" if not args.dry_run: _materialize_prompt( provider_name=args.provider, prompt_text=prompt["prompt_text"], language=prompt["language"], sample_rate=args.sample_rate, output_path=output_path, ) if preview_dir is not None: preview_path = preview_dir / output_path.name shutil.copyfile(output_path, preview_path) preview_path.chmod(0o644) generated.append( { **prompt, "output_path": str(output_path), } ) print(json.dumps({"provider": args.provider, "count": len(generated), "prompts": generated}, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())