Mejoras de Audio y funconalidad en caliente voice.cfg (#27)
* Update adn.cfg
* Update replit.md
* Update config.py
* Update bridge_master.py
* Fix: corregir indentacion en llamada a _handleRecording linea 2846
* Mover configuracion de locuciones y grabaciones a voice.cfg
* Nuevo archivo voice.cfg con configuracion de locuciones y grabaciones
* Cargar locuciones/grabaciones desde voice.cfg (opcional, con fallback a defaults)
* Actualizar documentacion: voice.cfg separado de adn.cfg
* Agregar configuracion TTS (4 slots) en voice.cfg
* Leer configuracion TTS desde voice.cfg
* Nuevo modulo TTS: texto -> gTTS -> WAV -> vocoder -> AMBE
* Agregar gTTS a dependencias
* Integrar sistema TTS con scheduling y playback
* Agregar configuracion AMBEServer (DV3000 remoto via UDP) en voice.cfg
* Leer configuracion TTS_AMBESERVER_HOST/PORT desde voice.cfg
* Agregar cliente AMBEServer UDP (protocolo DV3000) para codificacion AMBE
* Fix: apply DMR 3-way interleave to AMBE frames from DV3000
DV3000 outputs raw 72-bit AMBE+2 frames but DMR voice bursts
require 3 frames interleaved into 216-bit payloads.
Interleave: burst_payload[i*3+j] = frame[j].bit[i] (ETSI TS 102 361-1)
Also uses proper AMBE silence frame for padding incomplete triplets.
* Fix: use RATET 33 (DMR AMBE+2) instead of RATEP (D-Star)
The DV3000 was being configured with RATEP parameters that correspond
to D-Star AMBE codec, not DMR AMBE+2. G4KLX AMBETools uses RATET 33
(rate table entry 33) for DMR which selects the correct codec.
Also removed incorrect interleaving - DMR voice bursts use raw AMBE
frames without additional interleaving (confirmed by G4KLX wav2ambe).
* Add reload_voice_config() for hot-reload of voice.cfg
* Hot-reload voice.cfg: auto-detect changes every 15s without restart
Checks voice.cfg mtime every 15 seconds. When changed, reloads all
voice/TTS config, stops old LoopingCall tasks and starts new ones
with updated settings. No server restart needed.
* Fix: resolve AMBEServer host explicitly, strip whitespace/quotes
Adds socket.gethostbyname() to resolve DNS or validate IP before
using it in sendto(). Also strips whitespace and quotes from host
value to prevent hidden characters from config parsing.
* Change default TG from 214 to 2 in voice.cfg
* Improve playFileOnRequest: add file existence check, better logging, catch all exceptions
* Filter announcements by TG: only send to peers active on matching talkgroup
* Filter announcements/TTS by active BRIDGES: only send to systems with static or active dynamic TG
* Add TTS_VOLUME parameter (tts_engine.py): adjustable dB volume for TTS audio
* Add TTS_VOLUME parameter (config.py): adjustable dB volume for TTS audio
* Add TTS_VOLUME parameter (config/voice.cfg): adjustable dB volume for TTS audio
* Run TTS conversion (gTTS+ffmpeg+AMBEServer) in separate thread via deferToThread to avoid blocking reactor
* TTS dual-slot: send announcements on both TS1 and TS2 based on active BRIDGES per system
* Remove TTS_ANNOUNCEMENT*_TIMESLOT: TS now auto-detected from BRIDGES (config.py)
* Remove TTS_ANNOUNCEMENT*_TIMESLOT: TS now auto-detected from BRIDGES (config/voice.cfg)
* Remove TTS_ANNOUNCEMENT*_TIMESLOT: TS now auto-detected from BRIDGES (bridge_master.py)
* Announcements dual-slot: auto-detect TS from BRIDGES, remove ANNOUNCEMENT*_TIMESLOT (bridge_master.py)
* Announcements dual-slot: auto-detect TS from BRIDGES, remove ANNOUNCEMENT*_TIMESLOT (config.py)
* Announcements dual-slot: auto-detect TS from BRIDGES, remove ANNOUNCEMENT*_TIMESLOT (config/voice.cfg)
* Fix on-demand playback timing drift: use absolute timing instead of sleep(0.058) to prevent audio cuts on long files
* Add broadcast queue: prevent simultaneous announcements causing audio micro-cuts. All announcements (AMBE + TTS) now queue sequentially with 1.5s gap between them.
* Fix frame interval: 54ms->60ms (DMR standard) + absolute timing on all voice functions to eliminate micro-cuts
* Adjust frame interval: 60ms->58ms for optimal playback speed on all voice functions
* Remove noisy ROUTER debug logs that block reactor and cause voice micro-cuts: eliminate hundreds of 'no change'/'NO ACTION' debug lines per cycle
* Move rule_timer_loop, statTrimmer, kaReporting to background threads to prevent reactor blocking during voice broadcasts - all logs preserved
* Fix RuntimeError: dictionary changed size during iteration - use list(BRIDGES) for thread-safe iteration in all functions that access BRIDGES
* Revert bridge_master.py to state of commit ce9e9b09e0
* Skip rule_timer_loop during active voice traffic to prevent GIL contention micro-cuts - all logs preserved
* Batch ROUTER debug logs into single write to reduce GIL contention during voice traffic - all log content preserved
* Add TTS_SPEED config for speech rate control (atempo filter in ffmpeg)
* Add TTS_SPEED config for speech rate control (atempo filter in ffmpeg)
* Add TTS_SPEED config for speech rate control (atempo filter in ffmpeg)
pull/29/head
parent
4e0895b9f1
commit
aa96b48359
@ -0,0 +1,124 @@
|
|||||||
|
[VOICE]
|
||||||
|
; Locuciones programadas
|
||||||
|
; ANNOUNCEMENT_FILE: Nombre del archivo .ambe (sin extension, se busca en Audio/<lang>/ondemand/)
|
||||||
|
; ANNOUNCEMENT_TG: Talkgroup donde se emite la locucion
|
||||||
|
; ANNOUNCEMENT_MODE: interval (cada X segundos) o hourly (a las horas en punto)
|
||||||
|
; ANNOUNCEMENT_INTERVAL: Intervalo en segundos (solo si ANNOUNCEMENT_MODE = interval)
|
||||||
|
; ANNOUNCEMENT_ENABLED: True/False para activar/desactivar
|
||||||
|
; ANNOUNCEMENT_LANGUAGE: Idioma para buscar el archivo (ej: es_ES, en_GB)
|
||||||
|
; NOTA: El timeslot se determina automaticamente segun BRIDGES (se emite en TS1, TS2 o ambos)
|
||||||
|
ANNOUNCEMENT_ENABLED: False
|
||||||
|
ANNOUNCEMENT_FILE: locucion
|
||||||
|
ANNOUNCEMENT_TG: 2
|
||||||
|
ANNOUNCEMENT_MODE: hourly
|
||||||
|
ANNOUNCEMENT_INTERVAL: 3600
|
||||||
|
ANNOUNCEMENT_LANGUAGE: es_ES
|
||||||
|
|
||||||
|
; Locucion programada 2
|
||||||
|
ANNOUNCEMENT2_ENABLED: False
|
||||||
|
ANNOUNCEMENT2_FILE: locucion2
|
||||||
|
ANNOUNCEMENT2_TG: 2
|
||||||
|
ANNOUNCEMENT2_MODE: hourly
|
||||||
|
ANNOUNCEMENT2_INTERVAL: 3600
|
||||||
|
ANNOUNCEMENT2_LANGUAGE: es_ES
|
||||||
|
|
||||||
|
; Locucion programada 3
|
||||||
|
ANNOUNCEMENT3_ENABLED: False
|
||||||
|
ANNOUNCEMENT3_FILE: locucion3
|
||||||
|
ANNOUNCEMENT3_TG: 2
|
||||||
|
ANNOUNCEMENT3_MODE: hourly
|
||||||
|
ANNOUNCEMENT3_INTERVAL: 3600
|
||||||
|
ANNOUNCEMENT3_LANGUAGE: es_ES
|
||||||
|
|
||||||
|
; Locucion programada 4
|
||||||
|
ANNOUNCEMENT4_ENABLED: False
|
||||||
|
ANNOUNCEMENT4_FILE: locucion4
|
||||||
|
ANNOUNCEMENT4_TG: 2
|
||||||
|
ANNOUNCEMENT4_MODE: hourly
|
||||||
|
ANNOUNCEMENT4_INTERVAL: 3600
|
||||||
|
ANNOUNCEMENT4_LANGUAGE: es_ES
|
||||||
|
|
||||||
|
; Grabaciones Locuciones
|
||||||
|
; Graba el trafico de voz en un TG/TS especifico y lo guarda como archivo .ambe
|
||||||
|
; El archivo grabado se puede usar despues en las locuciones programadas
|
||||||
|
; Duracion maxima de grabacion: 2 minutos 45 segundos
|
||||||
|
RECORDING_ENABLED: False
|
||||||
|
RECORDING_TG: 2
|
||||||
|
RECORDING_TIMESLOT: 2
|
||||||
|
RECORDING_FILE: grabacion
|
||||||
|
RECORDING_LANGUAGE: es_ES
|
||||||
|
|
||||||
|
; ============================================================================
|
||||||
|
; TTS (Text-to-Speech) - Locuciones desde archivos de texto
|
||||||
|
; ============================================================================
|
||||||
|
; Convierte archivos .txt en audio AMBE para emitir por DMR
|
||||||
|
; Pipeline: .txt -> gTTS -> .mp3 -> ffmpeg -> .wav -> vocoder -> .ambe
|
||||||
|
;
|
||||||
|
; TTS_VOCODER_CMD: Comando del vocoder externo para convertir WAV a AMBE
|
||||||
|
; Usa {wav} como placeholder del archivo WAV de entrada
|
||||||
|
; y {ambe} como placeholder del archivo AMBE de salida
|
||||||
|
; Ejemplo: /usr/local/bin/md380-vocoder -e {wav} {ambe}
|
||||||
|
; Si esta vacio y no hay AMBEServer, el sistema buscara un .ambe pre-convertido
|
||||||
|
TTS_VOCODER_CMD:
|
||||||
|
|
||||||
|
; AMBEServer (DV3000 remoto via UDP)
|
||||||
|
; Alternativa al vocoder local: conecta a un AMBEServer que tiene un DV3000/ThumbDV
|
||||||
|
; conectado por puerto serie. El AMBEServer expone el chip via UDP.
|
||||||
|
; Ver: https://github.com/marrold/AMBEServer
|
||||||
|
; Si se configura host, se usa AMBEServer como prioridad sobre TTS_VOCODER_CMD
|
||||||
|
; Puerto por defecto del AMBEServer: 2460
|
||||||
|
TTS_AMBESERVER_HOST:
|
||||||
|
TTS_AMBESERVER_PORT: 2460
|
||||||
|
|
||||||
|
; TTS_VOLUME: Ajuste de volumen del audio TTS en dB antes de codificar a AMBE
|
||||||
|
; Valores negativos reducen el volumen, positivos lo aumentan
|
||||||
|
; Ejemplos: -3 (un poco mas bajo), -6 (notablemente mas bajo), 0 (sin cambio)
|
||||||
|
; Por defecto: -3
|
||||||
|
TTS_VOLUME: -3
|
||||||
|
|
||||||
|
; TTS_SPEED: Factor de velocidad de lectura del texto TTS
|
||||||
|
; 1.0 = velocidad normal de gTTS (suele sonar lento)
|
||||||
|
; 1.2 = un 20% mas rapido (recomendado para espanol)
|
||||||
|
; 1.5 = un 50% mas rapido
|
||||||
|
; Rango valido: 0.5 a 2.0
|
||||||
|
; Por defecto: 1.0
|
||||||
|
TTS_SPEED: 1.0
|
||||||
|
|
||||||
|
; TTS programada 1
|
||||||
|
; TTS_ANNOUNCEMENT1_FILE: Nombre del archivo .txt (sin extension, se busca en Audio/<lang>/ondemand/)
|
||||||
|
; TTS_ANNOUNCEMENT1_TG: Talkgroup donde se emite la locucion TTS
|
||||||
|
; TTS_ANNOUNCEMENT1_MODE: interval (cada X segundos) o hourly (a las horas en punto)
|
||||||
|
; TTS_ANNOUNCEMENT1_INTERVAL: Intervalo en segundos (solo si MODE = interval)
|
||||||
|
; TTS_ANNOUNCEMENT1_ENABLED: True/False para activar/desactivar
|
||||||
|
; TTS_ANNOUNCEMENT1_LANGUAGE: Idioma para TTS y ruta del archivo (ej: es_ES, en_GB)
|
||||||
|
; NOTA: El timeslot se determina automaticamente segun BRIDGES (se emite en TS1, TS2 o ambos)
|
||||||
|
TTS_ANNOUNCEMENT1_ENABLED: False
|
||||||
|
TTS_ANNOUNCEMENT1_FILE: texto1
|
||||||
|
TTS_ANNOUNCEMENT1_TG: 2
|
||||||
|
TTS_ANNOUNCEMENT1_MODE: hourly
|
||||||
|
TTS_ANNOUNCEMENT1_INTERVAL: 3600
|
||||||
|
TTS_ANNOUNCEMENT1_LANGUAGE: es_ES
|
||||||
|
|
||||||
|
; TTS programada 2
|
||||||
|
TTS_ANNOUNCEMENT2_ENABLED: False
|
||||||
|
TTS_ANNOUNCEMENT2_FILE: texto2
|
||||||
|
TTS_ANNOUNCEMENT2_TG: 2
|
||||||
|
TTS_ANNOUNCEMENT2_MODE: hourly
|
||||||
|
TTS_ANNOUNCEMENT2_INTERVAL: 3600
|
||||||
|
TTS_ANNOUNCEMENT2_LANGUAGE: es_ES
|
||||||
|
|
||||||
|
; TTS programada 3
|
||||||
|
TTS_ANNOUNCEMENT3_ENABLED: False
|
||||||
|
TTS_ANNOUNCEMENT3_FILE: texto3
|
||||||
|
TTS_ANNOUNCEMENT3_TG: 2
|
||||||
|
TTS_ANNOUNCEMENT3_MODE: hourly
|
||||||
|
TTS_ANNOUNCEMENT3_INTERVAL: 3600
|
||||||
|
TTS_ANNOUNCEMENT3_LANGUAGE: es_ES
|
||||||
|
|
||||||
|
; TTS programada 4
|
||||||
|
TTS_ANNOUNCEMENT4_ENABLED: False
|
||||||
|
TTS_ANNOUNCEMENT4_FILE: texto4
|
||||||
|
TTS_ANNOUNCEMENT4_TG: 2
|
||||||
|
TTS_ANNOUNCEMENT4_MODE: hourly
|
||||||
|
TTS_ANNOUNCEMENT4_INTERVAL: 3600
|
||||||
|
TTS_ANNOUNCEMENT4_LANGUAGE: es_ES
|
||||||
@ -0,0 +1,420 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
Encoding options (in priority order):
|
||||||
|
1. AMBEServer (DV3000 remoto via UDP) - TTS_AMBESERVER_HOST/PORT in voice.cfg
|
||||||
|
2. External vocoder command - TTS_VOCODER_CMD in voice.cfg
|
||||||
|
3. Pre-converted .ambe file (bypass pipeline)
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
- gTTS (pip install gTTS)
|
||||||
|
- ffmpeg (system package)
|
||||||
|
- One of: AMBEServer, external vocoder, or pre-converted .ambe files
|
||||||
|
'''
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import wave
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
|
||||||
|
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):
|
||||||
|
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, volume_db=0, speed=1.0):
|
||||||
|
speed = max(0.5, min(2.0, speed))
|
||||||
|
_filters = []
|
||||||
|
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) Aplicando ajuste de volumen: %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) 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_vocoder(wav_path, ambe_path, vocoder_cmd):
|
||||||
|
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) 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 via vocoder externo: %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 _build_audio_packet(pcm_samples):
|
||||||
|
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):
|
||||||
|
if len(data) < 4:
|
||||||
|
return None
|
||||||
|
if 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:
|
||||||
|
_field_id = data[4]
|
||||||
|
if _field_id == DV3K_AMBE_FIELD_ID:
|
||||||
|
_num_bits = data[5]
|
||||||
|
_num_bytes = (_num_bits + 7) // 8
|
||||||
|
_ambe_data = data[6:6 + _num_bytes]
|
||||||
|
return _ambe_data
|
||||||
|
|
||||||
|
if _pkt_type == DV3K_TYPE_CONTROL:
|
||||||
|
logger.debug('(TTS-AMBESERVER) Control response received (field_id: 0x%02X)', data[4] if len(data) > 4 else 0)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_ambe_ambeserver(wav_path, ambe_path, host, port):
|
||||||
|
host = host.strip().strip('"').strip("'")
|
||||||
|
logger.info('(TTS-AMBESERVER) Conectando a AMBEServer %s:%d', host, port)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_resolved_ip = socket.gethostbyname(host)
|
||||||
|
if _resolved_ip != host:
|
||||||
|
logger.info('(TTS-AMBESERVER) Host %s resuelto a %s', host, _resolved_ip)
|
||||||
|
host = _resolved_ip
|
||||||
|
except socket.gaierror as e:
|
||||||
|
logger.error('(TTS-AMBESERVER) No se puede resolver el host "%s": %s', host, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sock.settimeout(5.0)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('(TTS-AMBESERVER) Error creando socket UDP: %s', e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
sock.sendto(DV3K_PRODID_REQ, (host, port))
|
||||||
|
data, addr = sock.recvfrom(1024)
|
||||||
|
if data[0] != DV3K_START_BYTE:
|
||||||
|
logger.error('(TTS-AMBESERVER) Respuesta invalida del AMBEServer')
|
||||||
|
sock.close()
|
||||||
|
return False
|
||||||
|
logger.info('(TTS-AMBESERVER) AMBEServer conectado correctamente')
|
||||||
|
except socket.timeout:
|
||||||
|
logger.error('(TTS-AMBESERVER) Timeout conectando a AMBEServer %s:%d - Verifica que el servidor esta activo', host, port)
|
||||||
|
sock.close()
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('(TTS-AMBESERVER) Error conectando a AMBEServer: %s', e)
|
||||||
|
sock.close()
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
sock.sendto(DV3K_RATET_DMR, (host, port))
|
||||||
|
data, addr = sock.recvfrom(1024)
|
||||||
|
if data[0] != DV3K_START_BYTE:
|
||||||
|
logger.error('(TTS-AMBESERVER) Error configurando RATET DMR')
|
||||||
|
sock.close()
|
||||||
|
return False
|
||||||
|
logger.info('(TTS-AMBESERVER) RATET DMR (AMBE+2 tabla 33) configurado')
|
||||||
|
except socket.timeout:
|
||||||
|
logger.error('(TTS-AMBESERVER) Timeout configurando RATET')
|
||||||
|
sock.close()
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
wf = wave.open(wav_path, 'rb')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('(TTS-AMBESERVER) Error abriendo WAV: %s', e)
|
||||||
|
sock.close()
|
||||||
|
return False
|
||||||
|
|
||||||
|
if wf.getsampwidth() != 2 or wf.getnchannels() != 1:
|
||||||
|
logger.error('(TTS-AMBESERVER) WAV debe ser mono 16-bit PCM')
|
||||||
|
wf.close()
|
||||||
|
sock.close()
|
||||||
|
return False
|
||||||
|
|
||||||
|
_total_frames = wf.getnframes()
|
||||||
|
_sample_rate = wf.getframerate()
|
||||||
|
logger.info('(TTS-AMBESERVER) WAV: %d muestras, %d Hz, duracion: %.1f s',
|
||||||
|
_total_frames, _sample_rate, _total_frames / _sample_rate)
|
||||||
|
|
||||||
|
_raw_frames = wf.readframes(_total_frames)
|
||||||
|
wf.close()
|
||||||
|
|
||||||
|
_samples = list(struct.unpack('<' + 'h' * (_total_frames), _raw_frames))
|
||||||
|
|
||||||
|
_ambe_frames = []
|
||||||
|
_frames_sent = 0
|
||||||
|
_frames_error = 0
|
||||||
|
|
||||||
|
for i in range(0, len(_samples), DV3K_SAMPLES_PER_FRAME):
|
||||||
|
_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, addr = sock.recvfrom(1024)
|
||||||
|
_ambe_data = _parse_ambe_response(data)
|
||||||
|
if _ambe_data is not None:
|
||||||
|
_ambe_frames.append(_ambe_data)
|
||||||
|
_frames_sent += 1
|
||||||
|
else:
|
||||||
|
_frames_error += 1
|
||||||
|
logger.debug('(TTS-AMBESERVER) Frame %d: respuesta no AMBE (tipo: 0x%02X)',
|
||||||
|
i // DV3K_SAMPLES_PER_FRAME, data[3] if len(data) > 3 else 0)
|
||||||
|
except socket.timeout:
|
||||||
|
_frames_error += 1
|
||||||
|
logger.warning('(TTS-AMBESERVER) Timeout en frame %d', i // DV3K_SAMPLES_PER_FRAME)
|
||||||
|
except Exception as e:
|
||||||
|
_frames_error += 1
|
||||||
|
logger.error('(TTS-AMBESERVER) Error en frame %d: %s', i // DV3K_SAMPLES_PER_FRAME, e)
|
||||||
|
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
if not _ambe_frames:
|
||||||
|
logger.error('(TTS-AMBESERVER) No se recibieron frames AMBE')
|
||||||
|
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 escribiendo archivo AMBE: %s', e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info('(TTS-AMBESERVER) Codificacion completada: %d frames AMBE (%d errores), guardado en %s',
|
||||||
|
_frames_sent, _frames_error, ambe_path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def text_to_ambe(txt_path, ambe_path, language, vocoder_cmd, ambeserver_host='', ambeserver_port=2460, volume_db=0, speed=1.0):
|
||||||
|
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, volume_db, speed):
|
||||||
|
_cleanup([_mp3_path])
|
||||||
|
return False
|
||||||
|
|
||||||
|
_encoded = False
|
||||||
|
|
||||||
|
if ambeserver_host:
|
||||||
|
logger.info('(TTS) Usando AMBEServer %s:%d para codificacion AMBE', ambeserver_host, ambeserver_port)
|
||||||
|
_encoded = _encode_ambe_ambeserver(_wav_path, ambe_path, ambeserver_host, ambeserver_port)
|
||||||
|
if not _encoded:
|
||||||
|
logger.warning('(TTS) AMBEServer fallo, intentando vocoder externo...')
|
||||||
|
|
||||||
|
if not _encoded and vocoder_cmd:
|
||||||
|
logger.info('(TTS) Usando vocoder externo para codificacion AMBE')
|
||||||
|
_encoded = _encode_ambe_vocoder(_wav_path, ambe_path, vocoder_cmd)
|
||||||
|
|
||||||
|
if not _encoded:
|
||||||
|
logger.warning('(TTS) No se pudo codificar a AMBE. Archivos intermedios disponibles:')
|
||||||
|
logger.warning('(TTS) MP3: %s', _mp3_path)
|
||||||
|
logger.warning('(TTS) WAV: %s', _wav_path)
|
||||||
|
logger.warning('(TTS) Opciones para codificar:')
|
||||||
|
logger.warning('(TTS) 1. Configura TTS_AMBESERVER_HOST en voice.cfg (DV3000 remoto)')
|
||||||
|
logger.warning('(TTS) 2. Configura TTS_VOCODER_CMD en voice.cfg (vocoder local)')
|
||||||
|
logger.warning('(TTS) 3. Convierte manualmente el WAV a AMBE y guardalo como: %s', ambe_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', '')
|
||||||
|
_ambeserver_host = config['GLOBAL'].get('TTS_AMBESERVER_HOST', '')
|
||||||
|
_ambeserver_port = config['GLOBAL'].get('TTS_AMBESERVER_PORT', 2460)
|
||||||
|
_volume_db = config['GLOBAL'].get('TTS_VOLUME', -3)
|
||||||
|
_speed = config['GLOBAL'].get('TTS_SPEED', 1.0)
|
||||||
|
|
||||||
|
_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, _ambeserver_host, _ambeserver_port, _volume_db, _speed):
|
||||||
|
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
|
||||||
Loading…
Reference in new issue