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/tts_engine.py

225 lines
7.8 KiB

#!/usr/bin/env python
#
###############################################################################
# Copyright (C) 2026 Joaquin Madrid Belando, EA5GVK <ea5gvk@gmail.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 for ADN-DMR-Peer-Server
Converts text files (.txt) to AMBE audio files (.ambe) for DMR transmission.
Pipeline: .txt -> gTTS -> .mp3 -> ffmpeg -> .wav (8kHz mono 16-bit) -> vocoder -> .ambe
Requires:
- gTTS (pip install gTTS)
- ffmpeg (system package)
- External AMBE vocoder (configured via TTS_VOCODER_CMD in voice.cfg)
Example vocoders: md380-vocoder, DV3000/ThumbDV serial interface
'''
import os
import subprocess
import logging
logger = logging.getLogger(__name__)
_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',
}
def _get_tts_lang(announcement_language):
if announcement_language in _LANG_MAP:
return _LANG_MAP[announcement_language]
return announcement_language[:2]
def _generate_tts_audio(text, lang, mp3_path):
try:
from gtts import gTTS
except ImportError:
logger.error('(TTS) gTTS no esta instalado. Ejecuta: pip install gTTS')
return False
try:
tts = gTTS(text=text, lang=lang, slow=False)
tts.save(mp3_path)
logger.info('(TTS) Audio TTS generado: %s', mp3_path)
return True
except Exception as e:
logger.error('(TTS) Error generando audio TTS: %s', e)
return False
def _convert_to_wav(mp3_path, wav_path):
try:
result = subprocess.run(
['ffmpeg', '-y', '-i', mp3_path,
'-ar', '8000', '-ac', '1', '-sample_fmt', 's16',
'-f', 'wav', wav_path],
capture_output=True, timeout=60
)
if result.returncode != 0:
logger.error('(TTS) Error ffmpeg: %s', result.stderr.decode('utf-8', errors='ignore')[:500])
return False
logger.info('(TTS) Audio convertido a WAV 8kHz mono: %s', wav_path)
return True
except FileNotFoundError:
logger.error('(TTS) ffmpeg no encontrado. Instala ffmpeg en el sistema')
return False
except subprocess.TimeoutExpired:
logger.error('(TTS) Timeout en conversion ffmpeg')
return False
except Exception as e:
logger.error('(TTS) Error en conversion de audio: %s', e)
return False
def _encode_ambe(wav_path, ambe_path, vocoder_cmd):
if not vocoder_cmd:
logger.error('(TTS) No hay vocoder configurado (TTS_VOCODER_CMD vacio). '
'Configura la ruta al vocoder en voice.cfg o coloca un archivo .ambe pre-convertido')
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) Error del vocoder: %s', result.stderr.decode('utf-8', errors='ignore')[:500])
return False
if not os.path.isfile(ambe_path):
logger.error('(TTS) El vocoder no genero el archivo AMBE: %s', ambe_path)
return False
logger.info('(TTS) Audio codificado a AMBE: %s', ambe_path)
return True
except FileNotFoundError:
logger.error('(TTS) Comando vocoder no encontrado: %s', cmd.split()[0] if cmd else '(vacio)')
return False
except subprocess.TimeoutExpired:
logger.error('(TTS) Timeout en codificacion AMBE')
return False
except Exception as e:
logger.error('(TTS) Error ejecutando vocoder: %s', e)
return False
def text_to_ambe(txt_path, ambe_path, language, vocoder_cmd):
if not os.path.isfile(txt_path):
logger.warning('(TTS) Archivo de texto no encontrado: %s', txt_path)
return False
if os.path.isfile(ambe_path):
txt_mtime = os.path.getmtime(txt_path)
ambe_mtime = os.path.getmtime(ambe_path)
if ambe_mtime > txt_mtime:
logger.info('(TTS) Usando AMBE cacheado (mas reciente que .txt): %s', ambe_path)
return True
with open(txt_path, 'r', encoding='utf-8') as f:
text = f.read().strip()
if not text:
logger.warning('(TTS) Archivo de texto vacio: %s', txt_path)
return False
logger.info('(TTS) Convirtiendo texto a AMBE: %s (%d caracteres, idioma: %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):
_cleanup([_mp3_path])
return False
if not vocoder_cmd:
logger.warning('(TTS) No hay vocoder configurado. Archivos intermedios disponibles:')
logger.warning('(TTS) MP3: %s', _mp3_path)
logger.warning('(TTS) WAV: %s', _wav_path)
logger.warning('(TTS) Convierte manualmente el WAV a AMBE y guardalo como: %s', ambe_path)
return False
if not _encode_ambe(_wav_path, ambe_path, vocoder_cmd):
_cleanup([_mp3_path])
return False
_cleanup([_mp3_path, _wav_path])
logger.info('(TTS) Conversion completada: %s -> %s', txt_path, ambe_path)
return True
def _cleanup(files):
for f in files:
try:
if os.path.isfile(f):
os.remove(f)
except Exception:
pass
def ensure_tts_ambe(config, tts_num):
_prefix = 'TTS_ANNOUNCEMENT{}'.format(tts_num)
if not config['GLOBAL'].get('{}_ENABLED'.format(_prefix), False):
return None
_file = config['GLOBAL']['{}_FILE'.format(_prefix)]
_lang = config['GLOBAL']['{}_LANGUAGE'.format(_prefix)]
_vocoder_cmd = config['GLOBAL'].get('TTS_VOCODER_CMD', '')
_txt_path = './Audio/{}/ondemand/{}.txt'.format(_lang, _file)
_ambe_path = './Audio/{}/ondemand/{}.ambe'.format(_lang, _file)
if os.path.isfile(_ambe_path):
if not os.path.isfile(_txt_path):
logger.info('(TTS-%d) Usando archivo AMBE existente (sin .txt): %s', tts_num, _ambe_path)
return _ambe_path
txt_mtime = os.path.getmtime(_txt_path)
ambe_mtime = os.path.getmtime(_ambe_path)
if ambe_mtime > txt_mtime:
logger.debug('(TTS-%d) Usando AMBE cacheado: %s', tts_num, _ambe_path)
return _ambe_path
if not os.path.isfile(_txt_path):
logger.warning('(TTS-%d) Archivo de texto no encontrado: %s', tts_num, _txt_path)
return None
if text_to_ambe(_txt_path, _ambe_path, _lang, _vocoder_cmd):
return _ambe_path
else:
if os.path.isfile(_ambe_path):
logger.warning('(TTS-%d) Usando AMBE anterior (conversion fallo): %s', tts_num, _ambe_path)
return _ambe_path
return None

Powered by TurnKey Linux.