253 lines
9.3 KiB
Python
253 lines
9.3 KiB
Python
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 <prompt_audio_key>.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())
|