You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ADN-DMR-Peer-Server/plugins/voice-announcements/plugin/infrastructure/tts_engine.py

451 lines
17 KiB

# ADN DMR Peer Server - TTS engine (legacy tts_engine.py)
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
# Derived from ADN DMR Server / FreeDMR / HBlink. Original license:
###############################################################################
# Copyright (C) 2026 Joaquin Madrid Belando, EA5GVK <ea5gvk@gmail.com>
# Copyright (C) 2020 Simon Adlem, G7RZU <g7rzu@gb7fr.org.uk>
# Copyright (C) 2016-2019 Cortney T. Buffington, N0MJS <n0mjs@me.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""
TTS Engine: convert .txt to .ambe for DMR.
Pipeline: .txt -> gTTS -> .mp3 -> ffmpeg -> .wav (8kHz mono 16-bit) -> vocoder/AMBEServer -> .ambe
"""
from __future__ import annotations
import logging
import os
import socket
import struct
import subprocess
import threading
import time
import wave
from typing import Any
logger = logging.getLogger(__name__)
# One TTS conversion at a time (shared AMBEServer / vocoder cannot handle parallel sessions).
_tts_conversion_lock = threading.Lock()
_AMBESERVER_FRAME_TIMEOUT_S = 5.0
_AMBESERVER_ENCODE_TIMEOUT_S = 120.0
_AMBESERVER_MAX_CONSECUTIVE_ERRORS = 10
_AMBESERVER_PROGRESS_EVERY_FRAMES = 100
_LANG_MAP = {
"es_ES": "es", "en_GB": "en", "en_US": "en", "fr_FR": "fr",
"de_DE": "de", "it_IT": "it", "pt_PT": "pt", "pt_BR": "pt",
"pl_PL": "pl", "nl_NL": "nl", "da_DK": "da", "sv_SE": "sv",
"no_NO": "no", "el_GR": "el", "th_TH": "th", "cy_GB": "cy",
"ca_ES": "ca", "gl_ES": "gl", "eu_ES": "eu",
}
DV3K_START_BYTE = 0x61
DV3K_TYPE_CONTROL = 0x00
DV3K_TYPE_AMBE = 0x01
DV3K_TYPE_AUDIO = 0x02
DV3K_AMBE_FIELD_ID = 0x01
DV3K_AUDIO_FIELD_ID = 0x00
DV3K_SAMPLES_PER_FRAME = 160
DV3K_RATET_DMR = bytes([0x61, 0x00, 0x02, 0x00, 0x09, 0x21])
DV3K_PRODID_REQ = bytes([0x61, 0x00, 0x01, 0x00, 0x30])
def _get_tts_lang(announcement_language: str) -> str:
if announcement_language in _LANG_MAP:
return _LANG_MAP[announcement_language]
return announcement_language[:2] if len(announcement_language) >= 2 else announcement_language
def _generate_tts_audio(text: str, lang: str, mp3_path: str) -> bool:
try:
from gtts import gTTS
except ImportError:
logger.error("(TTS) gTTS not installed. Run: pip install gTTS")
return False
try:
tts = gTTS(text=text, lang=lang, slow=False)
tts.save(mp3_path)
logger.info("(TTS) TTS audio generated: %s", mp3_path)
return True
except Exception as e:
logger.error("(TTS) Error generating TTS audio: %s", e)
return False
def _convert_to_wav(mp3_path: str, wav_path: str, volume_db: int = 0, speed: float = 1.0) -> bool:
speed = max(0.5, min(2.0, speed))
_filters: list[str] = []
if speed != 1.0:
_filters.append('atempo={:.2f}'.format(speed))
logger.info('(TTS) Aplicando velocidad: x%.2f', speed)
if volume_db != 0:
_filters.append('volume={}dB'.format(volume_db))
logger.info("(TTS) Applying volume adjustment: %ddB", volume_db)
cmd = ["ffmpeg", "-y", "-i", mp3_path, "-ar", "8000", "-ac", "1", "-sample_fmt", "s16"]
if _filters:
cmd += ["-af", ",".join(_filters)]
cmd += ["-f", "wav", wav_path]
try:
result = subprocess.run(cmd, capture_output=True, timeout=60)
if result.returncode != 0:
logger.error("(TTS) ffmpeg error: %s", (result.stderr or b"").decode("utf-8", errors="ignore")[:500])
return False
logger.info("(TTS) Audio converted to 8kHz mono WAV: %s", wav_path)
return True
except FileNotFoundError:
logger.error("(TTS) ffmpeg not found. Install ffmpeg on the system")
return False
except subprocess.TimeoutExpired:
logger.error("(TTS) ffmpeg conversion timeout")
return False
except Exception as e:
logger.error("(TTS) Audio conversion error: %s", e)
return False
def _encode_ambe_vocoder(wav_path: str, ambe_path: str, vocoder_cmd: str) -> bool:
if not vocoder_cmd:
return False
cmd = vocoder_cmd.replace("{wav}", wav_path).replace("{ambe}", ambe_path)
try:
result = subprocess.run(cmd, shell=True, capture_output=True, timeout=120)
if result.returncode != 0:
logger.error("(TTS) Vocoder error: %s", (result.stderr or b"").decode("utf-8", errors="ignore")[:500])
return False
if not os.path.isfile(ambe_path):
logger.error("(TTS) Vocoder did not produce AMBE file: %s", ambe_path)
return False
logger.info("(TTS) Audio encoded to AMBE via external vocoder: %s", ambe_path)
return True
except subprocess.TimeoutExpired:
logger.error("(TTS) AMBE encoding timeout")
return False
except Exception as e:
logger.error("(TTS) Error running vocoder: %s", e)
return False
def _build_audio_packet(pcm_samples: list[int]) -> bytes:
payload = struct.pack("BB", DV3K_AUDIO_FIELD_ID, len(pcm_samples))
for sample in pcm_samples:
payload += struct.pack(">h", sample)
header = bytes([DV3K_START_BYTE]) + struct.pack(">HB", len(payload), DV3K_TYPE_AUDIO)
return header + payload
def _parse_ambe_response(data: bytes) -> bytes | None:
if len(data) < 4 or data[0] != DV3K_START_BYTE:
return None
_payload_len = struct.unpack(">H", data[1:3])[0]
_pkt_type = data[3]
if _pkt_type == DV3K_TYPE_AMBE and len(data) > 5:
_field_id = data[4]
if _field_id == DV3K_AMBE_FIELD_ID:
_num_bits = data[5]
_num_bytes = (_num_bits + 7) // 8
return data[6 : 6 + _num_bytes]
return None
def _encode_ambe_ambeserver(wav_path: str, ambe_path: str, host: str, port: int) -> bool:
host = host.strip().strip('"').strip("'")
logger.info("(TTS-AMBESERVER) Connecting to AMBEServer %s:%d", host, port)
try:
resolved = socket.gethostbyname(host)
if resolved != host:
logger.info("(TTS-AMBESERVER) Host %s resolved to %s", host, resolved)
host = resolved
except socket.gaierror as e:
logger.error('(TTS-AMBESERVER) Cannot resolve host "%s": %s', host, e)
return False
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(_AMBESERVER_FRAME_TIMEOUT_S)
except Exception as e:
logger.error("(TTS-AMBESERVER) Error creating UDP socket: %s", e)
return False
try:
sock.sendto(DV3K_PRODID_REQ, (host, port))
data, _ = sock.recvfrom(1024)
if data[0] != DV3K_START_BYTE:
logger.error("(TTS-AMBESERVER) Invalid response from AMBEServer")
sock.close()
return False
logger.info("(TTS-AMBESERVER) AMBEServer connected")
except socket.timeout:
logger.error("(TTS-AMBESERVER) Timeout connecting to AMBEServer %s:%d", host, port)
sock.close()
return False
except Exception as e:
logger.error("(TTS-AMBESERVER) Connection error: %s", e)
sock.close()
return False
try:
sock.sendto(DV3K_RATET_DMR, (host, port))
data, _ = sock.recvfrom(1024)
if data[0] != DV3K_START_BYTE:
logger.error("(TTS-AMBESERVER) Error setting RATET DMR")
sock.close()
return False
logger.info("(TTS-AMBESERVER) RATET DMR configured")
except (socket.timeout, Exception):
sock.close()
return False
try:
wf = wave.open(wav_path, "rb")
except Exception as e:
logger.error("(TTS-AMBESERVER) Error opening WAV: %s", e)
sock.close()
return False
if wf.getsampwidth() != 2 or wf.getnchannels() != 1:
logger.error("(TTS-AMBESERVER) WAV must be mono 16-bit PCM")
wf.close()
sock.close()
return False
_total_frames = wf.getnframes()
_sample_rate = wf.getframerate()
_duration_s = _total_frames / _sample_rate if _sample_rate else 0.0
_pcm_frames = (_total_frames + DV3K_SAMPLES_PER_FRAME - 1) // DV3K_SAMPLES_PER_FRAME
logger.info(
"(TTS-AMBESERVER) WAV: %d samples, %d Hz, duration: %.1fs (~%d AMBE frames)",
_total_frames,
_sample_rate,
_duration_s,
_pcm_frames,
)
_raw_frames = wf.readframes(_total_frames)
wf.close()
_samples = list(struct.unpack("<" + "h" * _total_frames, _raw_frames))
_ambe_frames: list[bytes] = []
_frames_sent = 0
_frames_error = 0
_consecutive_errors = 0
_encode_deadline = time.monotonic() + _AMBESERVER_ENCODE_TIMEOUT_S
for i in range(0, len(_samples), DV3K_SAMPLES_PER_FRAME):
_frame_idx = i // DV3K_SAMPLES_PER_FRAME
if time.monotonic() >= _encode_deadline:
logger.error(
"(TTS-AMBESERVER) Encoding timeout after %ss (%d/%d frames)",
int(_AMBESERVER_ENCODE_TIMEOUT_S),
_frames_sent,
_pcm_frames,
)
break
_chunk = _samples[i : i + DV3K_SAMPLES_PER_FRAME]
if len(_chunk) < DV3K_SAMPLES_PER_FRAME:
_chunk = _chunk + [0] * (DV3K_SAMPLES_PER_FRAME - len(_chunk))
_audio_pkt = _build_audio_packet(_chunk)
try:
sock.sendto(_audio_pkt, (host, port))
data, _ = sock.recvfrom(1024)
_ambe_data = _parse_ambe_response(data)
if _ambe_data is not None:
_ambe_frames.append(_ambe_data)
_frames_sent += 1
_consecutive_errors = 0
else:
_frames_error += 1
_consecutive_errors += 1
logger.debug(
"(TTS-AMBESERVER) Frame %d: non-AMBE response (type: 0x%02X)",
_frame_idx,
data[3] if len(data) > 3 else 0,
)
except socket.timeout:
_frames_error += 1
_consecutive_errors += 1
logger.warning("(TTS-AMBESERVER) Timeout on frame %d", _frame_idx)
except Exception as e:
_frames_error += 1
_consecutive_errors += 1
logger.error("(TTS-AMBESERVER) Error on frame %d: %s", _frame_idx, e)
if _consecutive_errors >= _AMBESERVER_MAX_CONSECUTIVE_ERRORS:
logger.error(
"(TTS-AMBESERVER) Aborting after %d consecutive frame errors (%d/%d sent)",
_consecutive_errors,
_frames_sent,
_pcm_frames,
)
break
if _frame_idx > 0 and _frame_idx % _AMBESERVER_PROGRESS_EVERY_FRAMES == 0:
logger.info(
"(TTS-AMBESERVER) Progress: %d/%d frames encoded",
_frames_sent,
_pcm_frames,
)
sock.close()
if not _ambe_frames:
logger.error("(TTS-AMBESERVER) No AMBE frames received")
return False
try:
with open(ambe_path, "wb") as f:
for frame in _ambe_frames:
f.write(frame)
except Exception as e:
logger.error("(TTS-AMBESERVER) Error writing AMBE: %s", e)
return False
logger.info(
"(TTS-AMBESERVER) Encoding completed: %d frames (%d errors): %s",
_frames_sent,
_frames_error,
ambe_path,
)
return True
def _cleanup(files: list[str]) -> None:
for f in files:
try:
if os.path.isfile(f):
os.remove(f)
except Exception as e:
logger.warning("(TTS) cleanup remove %s failed: %s", f, e)
def _cleanup(files: list[str]) -> None:
for f in files:
try:
if os.path.isfile(f):
os.remove(f)
except Exception as e:
logger.warning("(TTS) cleanup remove %s failed: %s", f, e)
def _ambe_cache_valid(txt_path: str, ambe_path: str) -> bool:
if not os.path.isfile(ambe_path):
return False
if not os.path.isfile(txt_path):
return True
return os.path.getmtime(ambe_path) > os.path.getmtime(txt_path)
def _text_to_ambe_uncached(
txt_path: str,
ambe_path: str,
language: str,
vocoder_cmd: str,
ambeserver_host: str,
ambeserver_port: int,
volume_db: int,
speed: float,
) -> bool:
with open(txt_path, "r", encoding="utf-8") as f:
text = f.read().strip()
if not text:
logger.warning("(TTS) Text file is empty: %s", txt_path)
return False
logger.info("(TTS) Converting text to AMBE: %s (%d chars, language: %s)", txt_path, len(text), language)
_dir = os.path.dirname(ambe_path)
if _dir:
os.makedirs(_dir, exist_ok=True)
_base = os.path.splitext(ambe_path)[0]
_mp3_path = _base + ".mp3"
_wav_path = _base + ".wav"
_tts_lang = _get_tts_lang(language)
if not _generate_tts_audio(text, _tts_lang, _mp3_path):
return False
if not _convert_to_wav(_mp3_path, _wav_path, volume_db, speed):
_cleanup([_mp3_path])
return False
_encoded = False
if ambeserver_host:
logger.info("(TTS) Using AMBEServer %s:%d", ambeserver_host, ambeserver_port)
_encoded = _encode_ambe_ambeserver(_wav_path, ambe_path, ambeserver_host, ambeserver_port)
if not _encoded:
logger.warning("(TTS) AMBEServer failed, trying external vocoder...")
if not _encoded and vocoder_cmd:
logger.info("(TTS) Using external vocoder")
_encoded = _encode_ambe_vocoder(_wav_path, ambe_path, vocoder_cmd)
if not _encoded:
logger.warning("(TTS) Could not encode to AMBE. Configure TTS_AMBESERVER_HOST or TTS_VOCODER_CMD.")
_cleanup([_mp3_path, _wav_path])
return False
_cleanup([_mp3_path, _wav_path])
logger.info("(TTS) Conversion completed: %s -> %s", txt_path, ambe_path)
return True
def text_to_ambe(
txt_path: str,
ambe_path: str,
language: str,
vocoder_cmd: str,
ambeserver_host: str = "",
ambeserver_port: int = 2460,
volume_db: int = 0,
speed: float = 1.0,
) -> bool:
"""Convert .txt to .ambe (gTTS -> mp3 -> ffmpeg -> wav -> vocoder/AMBEServer)."""
if not os.path.isfile(txt_path):
logger.warning("(TTS) Text file not found: %s", txt_path)
return False
if _ambe_cache_valid(txt_path, ambe_path):
logger.info("(TTS) Using cached AMBE (newer than .txt): %s", ambe_path)
return True
with _tts_conversion_lock:
if _ambe_cache_valid(txt_path, ambe_path):
logger.info("(TTS) Using cached AMBE (newer than .txt): %s", ambe_path)
return True
return _text_to_ambe_uncached(
txt_path,
ambe_path,
language,
vocoder_cmd,
ambeserver_host,
ambeserver_port,
volume_db,
speed,
)
def ensure_tts_ambe(config: dict[str, Any], item: dict[str, Any], audio_path: str) -> str | None:
"""Ensure .ambe exists for TTS item; create from .txt if needed. Returns path or None."""
if not item.get("ENABLED", False):
return None
_file = str(item.get("FILE") or "").strip()
_lang = item.get("LANGUAGE", "en_GB")
if not _file:
return None
g = config.get("VOICE", {})
_txt_path = os.path.join(audio_path, _lang, "ondemand", _file + ".txt")
_ambe_path = os.path.join(audio_path, _lang, "ondemand", _file + ".ambe")
if os.path.isfile(_ambe_path):
if not os.path.isfile(_txt_path):
logger.info("(TTS) Using existing AMBE file (no .txt): %s", _ambe_path)
return _ambe_path
if os.path.getmtime(_ambe_path) > os.path.getmtime(_txt_path):
logger.debug("(TTS) Using cached AMBE: %s", _ambe_path)
return _ambe_path
if not os.path.isfile(_txt_path):
logger.warning("(TTS) Text file not found: %s", _txt_path)
return None
_vocoder_cmd = g.get("TTS_VOCODER_CMD", "")
_ambeserver_host = (g.get("TTS_AMBESERVER_HOST") or "").strip()
_ambeserver_port = int(g.get("TTS_AMBESERVER_PORT", 2460))
_volume_db = int(g.get("TTS_VOLUME", -3))
_speed = float(g.get("TTS_SPEED", 1.0))
if text_to_ambe(_txt_path, _ambe_path, _lang, _vocoder_cmd, _ambeserver_host, _ambeserver_port, _volume_db, _speed):
return _ambe_path
if os.path.isfile(_ambe_path):
logger.warning("(TTS) Using previous AMBE (conversion failed): %s", _ambe_path)
return _ambe_path
return None

Powered by TurnKey Linux.