Compare commits

...

16 Commits

Author SHA1 Message Date
Joaquin Madrid Belando 91f44a9500
Merge pull request #32 from ea5gvk/pruebas
4 days ago
Joaquin Madrid Belando ecc3632887 Locuciones/TTS ilimitadas: deteccion dinamica desde voice.cfg (config/voice.cfg)
4 days ago
Joaquin Madrid Belando 5147a98935 Locuciones/TTS ilimitadas: deteccion dinamica desde voice.cfg (bridge_master.py)
4 days ago
Joaquin Madrid Belando 6c73491ff3 Locuciones/TTS ilimitadas: deteccion dinamica desde voice.cfg (config.py)
4 days ago
Joaquin Madrid Belando f43ba8efa9
Merge pull request #31 from ea5gvk/pruebas
1 week ago
Joaquin Madrid Belando 3a37220e14 Allow parallel broadcasts on different TGs - serialize only same-TG broadcasts, mark slots busy/free for TS conflict prevention
1 week ago
Joaquin Madrid Belando 0dcf7b1b66
Merge pull request #30 from ea5gvk/pruebas
2 weeks ago
Joaquin Madrid Belando 2158537d8a
Merge branch 'Amateur-Digital-Network:develop' into pruebas
2 weeks ago
Joaquin Madrid Belando 4fe00eba52
Update requirements.txt
2 weeks ago
Joaquin Madrid Belando be857f8176 Add ffmpeg as system dependency note in requirements.txt (required for TTS MP3 to WAV conversion)
2 weeks ago
Joaquin Madrid Belando ee36453ee8
Defer hourly announcement prep when broadcast queue is active to prevent reactor blocking and audio stuttering (#29)
2 weeks ago
Joaquin Madrid Belando aa96b48359
Mejoras de Audio y funconalidad en caliente voice.cfg (#27)
2 weeks ago
Esteban Mackay Q. 4e0895b9f1
Merge pull request #25 from hp3icc/develop
2 weeks ago
hp3icc 1f2668cc77 4dash
2 weeks ago
Esteban Mackay Q. 60c059ddce
Merge pull request #24 from hp3icc/develop
2 weeks ago
hp3icc e18c65c3a9 4dash
2 weeks ago

File diff suppressed because it is too large Load Diff

@ -31,6 +31,8 @@ change.
import configparser
import sys
import os
import re
import const
import socket
@ -44,8 +46,8 @@ __author__ = 'Cortney T. Buffington, N0MJS, Forked by Simon Adlem - G7RZU, F
__copyright__ = 'Copyright (c) 2016-2019 Cortney T. Buffington, N0MJS and the K0USY Group, Simon Adlem G7RZU 2020-2023, Esteban Mackay, HP3ICC 2024-2026'
__credits__ = 'Colin Durbridge, G4EML, Steve Zingman, N4IRS; Mike Zingman, N4IRR; Jonathan Naylor, G4KLX; Hans Barthen, DL5DI; Torsten Shultze, DG1HT; Jon Lee, G4TSN; Norman Williams, M6NBP, Eric Craw KF7EEL, Simon Adlem - G7RZU, Bruno Farias CS8ABG, Esteban Mackay HP3ICC, Joaquin Madrid Belando EA5GVK'
__license__ = 'GNU GPLv3'
__maintainer__ = 'Esteban Mackay, HP3ICC'
__email__ = 'setcom40@gmail.com'
__maintainer__ = 'Esteban Mackay, HP3ICC and Joaquin Madrid, EA5GVK'
__email__ = 'setcom40@gmail.com, ea5gvk@gmail.com'
# Processing of ALS goes here. It's separated from the acl_build function because this
# code is hblink config-file format specific, and acl_build is abstracted
@ -156,9 +158,7 @@ def build_config(_config_file):
'DATA_GATEWAY': config.getboolean(section, 'DATA_GATEWAY', fallback=False),
'VALIDATE_SERVER_IDS': config.getboolean(section, 'VALIDATE_SERVER_IDS', fallback=True),
'DEBUG_BRIDGES' : config.getboolean(section, 'DEBUG_BRIDGES', fallback=False),
'ENABLE_API' : config.getboolean(section, 'ENABLE_API', fallback=False)
'ENABLE_API' : config.getboolean(section, 'ENABLE_API', fallback=False),
})
if not CONFIG['GLOBAL']['ANNOUNCEMENT_LANGUAGES']:
CONFIG['GLOBAL']['ANNOUNCEMENT_LANGUAGES'] = languages
@ -396,9 +396,158 @@ def build_config(_config_file):
sys.exit('Error processing configuration file -- {}'.format(err))
process_acls(CONFIG)
_voice_cfg_dir = os.path.dirname(os.path.abspath(_config_file))
_voice_cfg_file = os.path.join(_voice_cfg_dir, 'voice.cfg')
voice_config = configparser.ConfigParser()
if os.path.isfile(_voice_cfg_file):
voice_config.read(_voice_cfg_file)
print('(CONFIG) Voice configuration loaded from {}'.format(_voice_cfg_file))
else:
print('(CONFIG) Voice configuration file not found ({}), using defaults (announcements/recording disabled)'.format(_voice_cfg_file))
_voice_section = 'VOICE'
_has_voice = voice_config.has_section(_voice_section)
_ann_nums = []
_tts_nums = []
if _has_voice:
for _key in voice_config.options(_voice_section):
_key_upper = _key.upper()
_m = re.match(r'^ANNOUNCEMENT(\d*)_ENABLED$', _key_upper)
if _m:
_ann_nums.append(1 if _m.group(1) == '' else int(_m.group(1)))
_m = re.match(r'^TTS_ANNOUNCEMENT(\d+)_ENABLED$', _key_upper)
if _m:
_tts_nums.append(int(_m.group(1)))
_ann_nums = sorted(set(_ann_nums))
_tts_nums = sorted(set(_tts_nums))
_voice_data = {
'_ANN_NUMS': list(_ann_nums),
'_TTS_NUMS': list(_tts_nums),
}
for _ann_num in _ann_nums:
_prefix = 'ANNOUNCEMENT' if _ann_num == 1 else 'ANNOUNCEMENT{}'.format(_ann_num)
_voice_data['{}_ENABLED'.format(_prefix)] = voice_config.getboolean(_voice_section, '{}_ENABLED'.format(_prefix), fallback=False) if _has_voice else False
_voice_data['{}_FILE'.format(_prefix)] = voice_config.get(_voice_section, '{}_FILE'.format(_prefix), fallback='locucion') if _has_voice else 'locucion'
_voice_data['{}_TG'.format(_prefix)] = voice_config.getint(_voice_section, '{}_TG'.format(_prefix), fallback=9) if _has_voice else 9
_voice_data['{}_MODE'.format(_prefix)] = voice_config.get(_voice_section, '{}_MODE'.format(_prefix), fallback='hourly') if _has_voice else 'hourly'
_voice_data['{}_INTERVAL'.format(_prefix)] = voice_config.getint(_voice_section, '{}_INTERVAL'.format(_prefix), fallback=3600) if _has_voice else 3600
_voice_data['{}_LANGUAGE'.format(_prefix)] = voice_config.get(_voice_section, '{}_LANGUAGE'.format(_prefix), fallback='es_ES') if _has_voice else 'es_ES'
_voice_data.update({
'RECORDING_ENABLED': voice_config.getboolean(_voice_section, 'RECORDING_ENABLED', fallback=False) if _has_voice else False,
'RECORDING_TG': voice_config.getint(_voice_section, 'RECORDING_TG', fallback=9) if _has_voice else 9,
'RECORDING_TIMESLOT': voice_config.getint(_voice_section, 'RECORDING_TIMESLOT', fallback=2) if _has_voice else 2,
'RECORDING_FILE': voice_config.get(_voice_section, 'RECORDING_FILE', fallback='grabacion') if _has_voice else 'grabacion',
'RECORDING_LANGUAGE': voice_config.get(_voice_section, 'RECORDING_LANGUAGE', fallback='es_ES') if _has_voice else 'es_ES',
'TTS_VOCODER_CMD': voice_config.get(_voice_section, 'TTS_VOCODER_CMD', fallback='') if _has_voice else '',
'TTS_AMBESERVER_HOST': voice_config.get(_voice_section, 'TTS_AMBESERVER_HOST', fallback='') if _has_voice else '',
'TTS_AMBESERVER_PORT': voice_config.getint(_voice_section, 'TTS_AMBESERVER_PORT', fallback=2460) if _has_voice else 2460,
'TTS_VOLUME': voice_config.getint(_voice_section, 'TTS_VOLUME', fallback=-3) if _has_voice else -3,
'TTS_SPEED': voice_config.getfloat(_voice_section, 'TTS_SPEED', fallback=1.0) if _has_voice else 1.0,
})
for _tts_num in _tts_nums:
_prefix = 'TTS_ANNOUNCEMENT{}'.format(_tts_num)
_voice_data['{}_ENABLED'.format(_prefix)] = voice_config.getboolean(_voice_section, '{}_ENABLED'.format(_prefix), fallback=False) if _has_voice else False
_voice_data['{}_FILE'.format(_prefix)] = voice_config.get(_voice_section, '{}_FILE'.format(_prefix), fallback='texto{}'.format(_tts_num)) if _has_voice else 'texto{}'.format(_tts_num)
_voice_data['{}_TG'.format(_prefix)] = voice_config.getint(_voice_section, '{}_TG'.format(_prefix), fallback=9) if _has_voice else 9
_voice_data['{}_MODE'.format(_prefix)] = voice_config.get(_voice_section, '{}_MODE'.format(_prefix), fallback='hourly') if _has_voice else 'hourly'
_voice_data['{}_INTERVAL'.format(_prefix)] = voice_config.getint(_voice_section, '{}_INTERVAL'.format(_prefix), fallback=3600) if _has_voice else 3600
_voice_data['{}_LANGUAGE'.format(_prefix)] = voice_config.get(_voice_section, '{}_LANGUAGE'.format(_prefix), fallback='es_ES') if _has_voice else 'es_ES'
CONFIG['GLOBAL'].update(_voice_data)
return CONFIG
def reload_voice_config(CONFIG, config_file):
_voice_cfg_dir = os.path.dirname(os.path.abspath(config_file))
_voice_cfg_file = os.path.join(_voice_cfg_dir, 'voice.cfg')
if not os.path.isfile(_voice_cfg_file):
return False
voice_config = configparser.ConfigParser()
try:
voice_config.read(_voice_cfg_file)
except Exception:
return False
_voice_section = 'VOICE'
_has_voice = voice_config.has_section(_voice_section)
if not _has_voice:
return False
_voice_keys = {}
_ann_nums = []
_tts_nums = []
for _key in voice_config.options(_voice_section):
_key_upper = _key.upper()
_m = re.match(r'^ANNOUNCEMENT(\d*)_ENABLED$', _key_upper)
if _m:
_ann_nums.append(1 if _m.group(1) == '' else int(_m.group(1)))
_m = re.match(r'^TTS_ANNOUNCEMENT(\d+)_ENABLED$', _key_upper)
if _m:
_tts_nums.append(int(_m.group(1)))
_ann_nums = sorted(set(_ann_nums))
_tts_nums = sorted(set(_tts_nums))
_voice_keys['_ANN_NUMS'] = list(_ann_nums)
_voice_keys['_TTS_NUMS'] = list(_tts_nums)
for _ann_num in _ann_nums:
_prefix = 'ANNOUNCEMENT' if _ann_num == 1 else 'ANNOUNCEMENT{}'.format(_ann_num)
_voice_keys['{}_ENABLED'.format(_prefix)] = voice_config.getboolean(_voice_section, '{}_ENABLED'.format(_prefix), fallback=False)
_voice_keys['{}_FILE'.format(_prefix)] = voice_config.get(_voice_section, '{}_FILE'.format(_prefix), fallback='locucion')
_voice_keys['{}_TG'.format(_prefix)] = voice_config.getint(_voice_section, '{}_TG'.format(_prefix), fallback=9)
_voice_keys['{}_MODE'.format(_prefix)] = voice_config.get(_voice_section, '{}_MODE'.format(_prefix), fallback='hourly')
_voice_keys['{}_INTERVAL'.format(_prefix)] = voice_config.getint(_voice_section, '{}_INTERVAL'.format(_prefix), fallback=3600)
_voice_keys['{}_LANGUAGE'.format(_prefix)] = voice_config.get(_voice_section, '{}_LANGUAGE'.format(_prefix), fallback='es_ES')
_voice_keys['RECORDING_ENABLED'] = voice_config.getboolean(_voice_section, 'RECORDING_ENABLED', fallback=False)
_voice_keys['RECORDING_TG'] = voice_config.getint(_voice_section, 'RECORDING_TG', fallback=9)
_voice_keys['RECORDING_TIMESLOT'] = voice_config.getint(_voice_section, 'RECORDING_TIMESLOT', fallback=2)
_voice_keys['RECORDING_FILE'] = voice_config.get(_voice_section, 'RECORDING_FILE', fallback='grabacion')
_voice_keys['RECORDING_LANGUAGE'] = voice_config.get(_voice_section, 'RECORDING_LANGUAGE', fallback='es_ES')
_voice_keys['TTS_VOCODER_CMD'] = voice_config.get(_voice_section, 'TTS_VOCODER_CMD', fallback='')
_voice_keys['TTS_AMBESERVER_HOST'] = voice_config.get(_voice_section, 'TTS_AMBESERVER_HOST', fallback='')
_voice_keys['TTS_AMBESERVER_PORT'] = voice_config.getint(_voice_section, 'TTS_AMBESERVER_PORT', fallback=2460)
_voice_keys['TTS_VOLUME'] = voice_config.getint(_voice_section, 'TTS_VOLUME', fallback=-3)
_voice_keys['TTS_SPEED'] = voice_config.getfloat(_voice_section, 'TTS_SPEED', fallback=1.0)
for _tts_num in _tts_nums:
_prefix = 'TTS_ANNOUNCEMENT{}'.format(_tts_num)
_voice_keys['{}_ENABLED'.format(_prefix)] = voice_config.getboolean(_voice_section, '{}_ENABLED'.format(_prefix), fallback=False)
_voice_keys['{}_FILE'.format(_prefix)] = voice_config.get(_voice_section, '{}_FILE'.format(_prefix), fallback='texto{}'.format(_tts_num))
_voice_keys['{}_TG'.format(_prefix)] = voice_config.getint(_voice_section, '{}_TG'.format(_prefix), fallback=9)
_voice_keys['{}_MODE'.format(_prefix)] = voice_config.get(_voice_section, '{}_MODE'.format(_prefix), fallback='hourly')
_voice_keys['{}_INTERVAL'.format(_prefix)] = voice_config.getint(_voice_section, '{}_INTERVAL'.format(_prefix), fallback=3600)
_voice_keys['{}_LANGUAGE'.format(_prefix)] = voice_config.get(_voice_section, '{}_LANGUAGE'.format(_prefix), fallback='es_ES')
_old_ann = CONFIG['GLOBAL'].get('_ANN_NUMS', [])
for _old_num in _old_ann:
if _old_num not in _ann_nums:
_old_prefix = 'ANNOUNCEMENT' if _old_num == 1 else 'ANNOUNCEMENT{}'.format(_old_num)
for _sfx in ('_ENABLED', '_FILE', '_TG', '_MODE', '_INTERVAL', '_LANGUAGE'):
CONFIG['GLOBAL'].pop('{}{}'.format(_old_prefix, _sfx), None)
_old_tts = CONFIG['GLOBAL'].get('_TTS_NUMS', [])
for _old_num in _old_tts:
if _old_num not in _tts_nums:
_old_prefix = 'TTS_ANNOUNCEMENT{}'.format(_old_num)
for _sfx in ('_ENABLED', '_FILE', '_TG', '_MODE', '_INTERVAL', '_LANGUAGE'):
CONFIG['GLOBAL'].pop('{}{}'.format(_old_prefix, _sfx), None)
CONFIG['GLOBAL'].update(_voice_keys)
return True
# Used to run this file direclty and print the config,
# which might be useful for debugging
if __name__ == '__main__':

@ -0,0 +1,134 @@
[VOICE]
; Locuciones programadas (AMBE pregrabado)
; Se pueden definir tantas locuciones como se necesiten.
; La primera usa prefijo ANNOUNCEMENT_, las siguientes ANNOUNCEMENT2_, ANNOUNCEMENT3_, etc.
; Para anadir mas, duplicar el bloque con el siguiente numero (ANNOUNCEMENT5_, ANNOUNCEMENT6_...)
; No hay limite en el numero de locuciones.
;
; 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)
; NOTA: Los cambios en este archivo se aplican automaticamente (hot-reload cada 15s)
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 programadas (Text-to-Speech desde archivos .txt)
; Se pueden definir tantas TTS como se necesiten: TTS_ANNOUNCEMENT1_, TTS_ANNOUNCEMENT2_, etc.
; Para anadir mas, duplicar el bloque con el siguiente numero (TTS_ANNOUNCEMENT5_, TTS_ANNOUNCEMENT6_...)
; No hay limite en el numero de anuncios TTS.
;
; 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

@ -45,11 +45,10 @@ services:
adn-dashboard:
container_name: adn-dashboard
mem_reservation: 512m
# Before enabling local mapping, download the file to the host machine:
# DASHBOARD Style: 1 for OA4DOA , dash 2 for CS8ABG
# file config dash 1 : docker cp monitor:/opt/FDMR-Monitor1/fdmr-mon.cfg .
# file config dash 2 : docker cp monitor:/opt/FDMR-Monitor2/fdmr-mon.cfg .
# volumes:
volumes:
- './sql:/opt/adn-dashboard/html/db'
- './mysql:/var/lib/mysql'
- './ssl:/opt/ssl'
# Dash1
# - './logo.png:/opt/FDMR-Monitor1/html/img/logo.png'
# - './favicon.ico:/opt/FDMR-Monitor1/html/img/favicon.ico'
@ -61,9 +60,13 @@ services:
# - './logo.png:/opt/adn-dashboard/html/img/banner_SAMPLE.png'
# - './bk.jpg:/opt/adn-dashboard/html/img/background_SAMPLE.jpg'
# - './favicon.ico:/opt/adn-dashboard/html/img/favicon_SAMPLE.ico'
# Dash4
# - './logo.png:/opt/adn-monitor/frontend/dist/img/logo.png:ro'
# - './bk.jpg:/opt/adn-monitor/frontend/dist/img/bk.jpg:ro'
# - './favicon.ico:/opt/adn-monitor/frontend/dist/favicon.ico:ro'
ports:
- '80:80/tcp'
- '443:443/tcp'
# - '443:443/tcp'
- '9000:9000'
depends_on:
- 'adn-server'
@ -71,7 +74,7 @@ services:
# For info TZ list: https://gist.github.com/alejzeis/ad5827eb14b5c22109ba652a1a267af5#file-timezone-mappings-csv
- TZ=America/Panama
# Select DASHBOARD Style: 1 to OA4DOA, 2 to CS8ABG, 3 to CS8ABG with Map
- DASHBOARD=3
- DASHBOARD=4
# Selfcare Button
- SELFCARE=False
# To enable SSL, enter the dashboard's DNS and your email account.
@ -89,9 +92,12 @@ services:
# Navbar Links Name, SAMPLE: NAV_LNK_NAME=LINKS
# LINKx put as many as you want, SAMPLE: LINK1=NameX,http://url.com
- NAV_LNK_NAME=
- LINK1=NameX,http://url.link
- LINK2=NameX,https://site.link
- LINK3=NameX,https://goaway.link
- LINK1=
- LINK2=
- LINK3=
# NEWSx put as many as you want, SAMPLE: NEWS1=NameX,http://url.com OR: NEWS1=ADN Systems
# - NEWS1=NameX,http://url.link
# - NEWS2=Text
# World Wide Server List, SAMPLE: http://url/Hosts.csv
- SERVER_LIST=
# World Wide Bridge List, SAMPLE: https://url/Bridges.csv

@ -45,12 +45,9 @@ services:
container_name: monitor
mem_reservation: 512m
restart: "always"
# Before enabling local mapping, download the file to the host machine:
# DASHBOARD Style: 1 for OA4DOA , dash 2 for CS8ABG
# file config dash 1 : docker cp monitor:/opt/FDMR-Monitor1/fdmr-mon.cfg .
# file config dash 2 : docker cp monitor:/opt/FDMR-Monitor2/fdmr-mon.cfg .
volumes:
- './sql:/opt/adn-dashboard/html/db'
- './mysql:/var/lib/mysql'
- './ssl:/opt/ssl'
# Dash1
# - './logo.png:/opt/FDMR-Monitor1/html/img/logo.png'
# - './favicon.ico:/opt/FDMR-Monitor1/html/img/favicon.ico'
@ -62,11 +59,14 @@ services:
# - './logo.png:/opt/adn-dashboard/html/img/banner_SAMPLE.png'
# - './bk.jpg:/opt/adn-dashboard/html/img/background_SAMPLE.jpg'
# - './favicon.ico:/opt/adn-dashboard/html/img/favicon_SAMPLE.ico'
# Dash4
# - './logo.png:/opt/adn-monitor/frontend/dist/img/logo.png:ro'
# - './bk.jpg:/opt/adn-monitor/frontend/dist/img/bk.jpg:ro'
# - './favicon.ico:/opt/adn-monitor/frontend/dist/favicon.ico:ro'
ports:
- '80:80/tcp'
# - '443:443/tcp'
# - '443:443/tcp'
- '9000:9000'
- '62031:62031/udp'
depends_on:
- 'adn-server'
environment:
@ -93,9 +93,12 @@ services:
# Navbar Links Name, SAMPLE: NAV_LNK_NAME=LINKS
# LINKx put as many as you want, SAMPLE: LINK1=NameX,http://url.com
- NAV_LNK_NAME=
- LINK1=NameX,http://url.link
- LINK2=NameX,https://site.link
- LINK3=NameX,https://goaway.link
- LINK1=
- LINK2=
- LINK3=
# NEWSx put as many as you want, SAMPLE: NEWS1=NameX,http://url.com OR: NEWS1=ADN Systems
# - NEWS1=NameX,http://url.link
# - NEWS2=Text
# World Wide Server List, SAMPLE: http://url/Hosts.csv
- SERVER_LIST=
# World Wide Bridge List, SAMPLE: https://url/Bridges.csv

@ -34,7 +34,6 @@ chmod -R 755 /etc/ADN-Systems &&
echo make json directory...
mkdir -p /etc/ADN-Systems/data &&
chown 54000:54000 /etc/ADN-Systems/data &&
echo Install /etc/ADN-Systems/adn.cfg ...
cat << EOF > /etc/ADN-Systems/adn.cfg
@ -199,106 +198,6 @@ OVERRIDE_IDENT_TG:
EOF
#
echo Install /etc/ADN-Systems/fdmr-mon.cfg ...
cat << EOF > /etc/ADN-Systems/fdmr-mon.cfg
[GLOBAL]
# Display Bridge status
BRIDGES_INC = False
# Display Peers status
HOMEBREW_INC = True
# Lastheard table on main page
LASTHEARD_ROWS = 20
# Display empty masters
EMPTY_MASTERS = False
# TG Count on TOP TG's page
TGCOUNT_ROWS = 20
[FDMR CONNECTION]
# FDMR server's IP Address or hostname
FDMR_IP = adn-server
# FDMR server's TCP reporting socket
FDMR_PORT = 4321
[OPB FILTER]
# if you don't want to show in lastherad received traffic from OBP link put NETWORK ID
# for example: 260210, 260211, 260212
OPB_FILTER =
[FILES]
# Files and stuff for loading alias files for mapping numbers to names
FILES_PATH = ./data
# This files will auto-download
PEER_FILE = peer_ids.json
SUBSCRIBER_FILE = subscriber_ids.json
TGID_FILE = talkgroup_ids.json
# User provided files, if you don't use it, you can comment it.
LOCAL_SUB_FILE = local_subscriber_ids.json
LOCAL_PEER_FILE = local_peer_ids.json
LOCAL_TGID_FILE = local_talkgroup_ids.json
# Number of days before we reload DMR-MARC database files.
RELOAD_TIME = 1
PEER_URL = https://servers.adn.systems/peer_ids.json
SUBSCRIBER_URL = https://servers.adn.systems/subscriber_ids.json
TGID_URL = https://servers.adn.systems/talkgroup_ids.json
[LOGGER]
# Settings for log files
LOG_PATH = /dev/
LOG_FILE = null
LOG_LEVEL = WARN
[WEBSOCKET SERVER]
WEBSOCKET_PORT = 9000
# Frequency to push updates to web clients
FREQUENCY = 1
# Clients are timed out after this many seconds, 0 to disable
CLIENT_TIMEOUT = 0
# SSL configuration
USE_SSL = False
SSL_PATH = ./ssl
SSL_CERTIFICATE = cert.pem
SSL_PRIVATEKEY = key.pem
[DASHBOARD]
# Dashboard Title
DASHTITLE = "DMR Server"
# Background image True or False if True put a bk.jpg 1920x1080 in img folder
BACKGROUND = False
# this defines the default language
# available languages: en, es, fr, pt, it, nl, de
LANGUAGE = "en"
# Navbar Title
NAVTITLE= "DMR Server"
# --Navbar Links-- #
#NAV_LNK_NAME = "Links"
#LINK1 = "Name 1", "http://url.link"
#LINK2 = "Name 2", "https://site.link"
#LINK3 = "Name 3", "https://goaway.link"
#LINKx put as many as you want
# World Wide Server List
#SERVER_LIST = "http://url/Hosts.txt"
# World Wide Bridge List
#BRIDGES_LIST = "https://url/Bridges.csv"
# World Wide TalkGroups List
#TG_LIST = "https://url/Talkgroups.csv"
#TELEGRAM = "url"
#WHATSAPP = "url"
#FACEBOOK = "url"
#SERVER_LIST = "http://yourwebsite/Hosts.txt"
# --Footer Links-- #
# Beginning of footer
#FOOTER1 = "SYSOP <a href='http://your.link'>N0CALL</a>"
# End of footer
#FOOTER2 = "Your Project <a href='http://your.link'>Project</a>"
EOF
echo Set perms on config directory...
chown -R 54000 /etc/ADN-Systems &&
echo Get docker-compose.yml...
cd /etc/ADN-Systems &&
curl https://raw.githubusercontent.com/Amateur-Digital-Network/ADN-DMR-Peer-Server/develop/docker-configs/docker-compose.yml -o docker-compose.yml &&
@ -308,21 +207,6 @@ if grep -q "Raspberry Pi" /proc/device-tree/model 2>/dev/null; then
sed -i "s/^mem_reservation/#mem_reservation/g" /etc/ADN-Systems/docker-compose.yml
fi
chmod 755 /etc/cron.daily/lastheard
echo Tune network stack...
cat << EOF > /etc/sysctl.conf &&
net.core.rmem_default=134217728
net.core.rmem_max=134217728
net.core.wmem_max=134217728
net.core.rmem_default=134217728
net.core.netdev_max_backlog=250000
net.netfilter.nf_conntrack_udp_timeout=15
net.netfilter.nf_conntrack_udp_timeout_stream=35
EOF
/usr/sbin/sysctl -p &&
echo Run ADN-Systems container...
docker-compose up -d

@ -35,7 +35,6 @@ chmod -R 755 /etc/ADN-Systems &&
echo make json directory...
mkdir -p /etc/ADN-Systems/data &&
chown 54000:54000 /etc/ADN-Systems/data &&
echo Install /etc/ADN-Systems/adn.cfg ...
cat << EOF > /etc/ADN-Systems/adn.cfg
@ -200,113 +199,7 @@ OVERRIDE_IDENT_TG:
EOF
#
echo Install /etc/ADN-Systems/fdmr-mon.cfg ...
cat << EOF > /etc/ADN-Systems/fdmr-mon.cfg
[GLOBAL]
# Display Bridge status
BRIDGES_INC = False
# Display Homebrew Peers status
HOMEBREW_INC = True
# Display lastheard table on main page
LASTHEARD_INC = True
LASTHEARD_ROWS = 20
# Display empty masters in status
EMPTY_MASTERS = False
# Display TG Count on tgcount page
TGCOUNT_INC = True
TGCOUNT_ROWS = 20
[SELF SERVICE]
# Database credentials, assuming you are running MySQL.
DB_SERVER = mariadb
DB_USERNAME = hbmon
DB_PASSWORD = hbmon
DB_NAME = hbmon
DB_PORT = 3306
[FDMR CONNECTION]
# FDMR server's IP Address
FDMR_IP = adn-server
# FDMR server's TCP reporting socket
FDMR_PORT = 4321
[OPB FILTER]
# Generally you don't need to use this but
# if you don't want to show in lastherad received traffic from OBP link put NETWORK ID
# for example: 260210, 260211, 260212
OPB_FILTER =
[FILES]
# Files and stuff for loading alias files for mapping numbers to names
FILES_PATH = ./data
# This files will auto-download
PEER_FILE = peer_ids.json
SUBSCRIBER_FILE = subscriber_ids.json
TGID_FILE = talkgroup_ids.json
# User provided files, if you don't use it, you can comment it.
LOCAL_SUB_FILE = local_subscriber_ids.json
LOCAL_PEER_FILE = local_peer_ids.json
LOCAL_TGID_FILE = local_talkgroup_ids.json
# Number of days before we reload DMR-MARC database files.
RELOAD_TIME = 1
PEER_URL = https://servers.adn.systems/peer_ids.json
SUBSCRIBER_URL = https://servers.adn.systems/subscriber_ids.json
TGID_URL = https://servers.adn.systems/talkgroup_ids.json
[LOGGER]
# Settings for log files
LOG_PATH = /dev/
LOG_FILE = null
LOG_LEVEL = WARN
[WEBSOCKET SERVER]
WEBSOCKET_PORT = 9000
# Frequency to push updates to web clients
FREQUENCY = 1
# Clients are timed out after this many seconds, 0 to disable
CLIENT_TIMEOUT = 0
# SSL configuration
USE_SSL = False
SSL_PATH = ./ssl
SSL_CERTIFICATE = cert.pem
SSL_PRIVATEKEY = key.pem
[DASHBOARD]
# Dashboard Title
DASHTITLE = "DMR Server"
# Background image True or False if True put a bk.jpg 1920x1080 in img folder
BACKGROUND = False
# this defines the default language
# available languages: en, es, fr, pt, it, nl, de
LANGUAGE = "es"
# Navbar Title
NAVTITLE= "DMR Server"
# --Navbar Links-- #
#NAV_LNK_NAME = "Links"
#LINK1 = "Name 1", "http://url.link"
#LINK2 = "Name 2", "https://site.link"
#LINK3 = "Name 3", "https://goaway.link"
#LINKx put as many as you want
# World Wide Server List
#SERVER_LIST = "https://adn.systems/servers/adn-servers.csv"
# World Wide Bridge List
#BRIDGES_LIST = "https://url/Bridges.csv"
# World Wide TalkGroups List
#TG_LIST = "https://url/Talkgroups.csv"
####TELEGRAM = "url"
#WHATSAPP = "url"
#FACEBOOK = "url"
# --Footer Links-- #
# Beginning of footer
#FOOTER1 = "SYSOP <a href='http://your.link'>N0CALL</a>"
# End of footer
#FOOTER2 = "Your Project <a href='http://your.link'>Project</a>"
EOF
echo Set perms on config directory...
chown -R 54000 /etc/ADN-Systems &&
echo Get docker-compose.yml...
cd /etc/ADN-Systems &&
@ -317,21 +210,6 @@ if grep -q "Raspberry Pi" /proc/device-tree/model 2>/dev/null; then
sed -i "s/^mem_reservation/#mem_reservation/g" /etc/ADN-Systems/docker-compose.yml
fi
chmod 755 /etc/cron.daily/lastheard
echo Tune network stack...
cat << EOF > /etc/sysctl.conf &&
net.core.rmem_default=134217728
net.core.rmem_max=134217728
net.core.wmem_max=134217728
net.core.rmem_default=134217728
net.core.netdev_max_backlog=250000
net.netfilter.nf_conntrack_udp_timeout=15
net.netfilter.nf_conntrack_udp_timeout_stream=35
EOF
/usr/sbin/sysctl -p &&
echo Run ADN-Systems container...
docker-compose up -d

@ -57,6 +57,65 @@ Preferred communication style: Simple, everyday language.
**Rationale**: Provides accessible feedback to users without requiring external TTS systems, maintains compatibility with DMR audio codecs.
### Scheduled Announcements (Locuciones Programadas)
**Problem**: Need to broadcast pre-recorded AMBE voice announcements on a schedule to specific talkgroups
**Solution**: Configurable scheduled announcement system in `bridge_master.py` with up to 4 independent announcement slots
**Configuration in config/voice.cfg [VOICE] section** (separated from adn.cfg):
- Slot 1: `ANNOUNCEMENT_*` (ENABLED, FILE, TG, TIMESLOT, MODE, INTERVAL, LANGUAGE)
- Slot 2: `ANNOUNCEMENT2_*` (same parameters with prefix ANNOUNCEMENT2_)
- Slot 3: `ANNOUNCEMENT3_*` (same parameters with prefix ANNOUNCEMENT3_)
- Slot 4: `ANNOUNCEMENT4_*` (same parameters with prefix ANNOUNCEMENT4_)
**Note**: voice.cfg is loaded automatically from the same directory as adn.cfg. If voice.cfg does not exist, the server starts normally with all announcements and recording disabled (safe defaults).
Each slot supports:
- `*_ENABLED`: True/False to enable/disable
- `*_FILE`: Name of the .ambe file (without extension, located in Audio/<lang>/ondemand/)
- `*_TG`: Talkgroup number where the announcement is broadcast
- `*_TIMESLOT`: Timeslot 1 or 2 (default: 2)
- `*_MODE`: `interval` (every X seconds) or `hourly` (at the top of each hour)
- `*_INTERVAL`: Interval in seconds (only used when mode is `interval`)
- `*_LANGUAGE`: Language folder for the AMBE file (e.g., es_ES, en_GB)
**Behavior**:
- Each slot runs independently with its own LoopingCall timer
- In `hourly` mode: checks every 30 seconds, plays only when minute == 0
- In `interval` mode: plays at the configured interval
- Broadcasts to ALL MASTER systems simultaneously (like bridge routing)
- Uses adaptive 60ms frame timing for clean audio
- Only plays on MASTER systems (not OPENBRIDGE), excludes ECHO/D-APRS
- Skips systems that are busy (RX or TX active)
- Audio source ID: 5000, uses SERVER_ID as peer ID
- Log labels: LOCUCION (slot 1), LOCUCION-2, LOCUCION-3, LOCUCION-4
### Voice Recording System (Grabaciones Locuciones)
**Problem**: Need to record voice announcements directly from radio traffic for later playback as scheduled announcements
**Solution**: AMBE voice recorder that captures traffic on a configured TG/TS
**Configuration in config/voice.cfg [VOICE] section** (same file as announcements):
- `RECORDING_ENABLED`: True/False to enable/disable recording
- `RECORDING_TG`: Talkgroup to monitor for recording
- `RECORDING_TIMESLOT`: Timeslot to monitor (1 or 2)
- `RECORDING_FILE`: Output filename (without .ambe extension)
- `RECORDING_LANGUAGE`: Language folder (determines save path: Audio/<lang>/ondemand/<file>.ambe)
**Behavior**:
- Records any voice transmission on the configured TG/TS
- Extracts raw AMBE bursts (108-bit pairs) from DMRD voice frames
- Maximum recording duration: 2 minutes 45 seconds (2750 frames)
- Automatically saves when transmission ends (voice terminator) or max duration reached
- Saved file is directly compatible with the announcement playback system
- Recording runs in the reactor thread (no blocking)
- Only processes validated frames (after duplicate/rate/loop checks)
- Log label: GRABACION
**Integration with Announcements**:
- Record with `RECORDING_FILE: mi_locucion` and `RECORDING_LANGUAGE: es_ES`
- Playback with `ANNOUNCEMENT_FILE: mi_locucion` and `ANNOUNCEMENT_LANGUAGE: es_ES`
### Individual Password Authentication
**Problem**: Need individual password authentication per Radio ID (indicativo) for enhanced security
@ -206,4 +265,4 @@ curl -L "http://URL_SECURITY:PORT_SECURITY/descargar?pass=PASS_SECURITY&user_pas
### Language/Voice Assets
- Pre-recorded AMBE voice files in Audio/ directory
- Multiple language support (en_GB, es_ES, fr_FR, de_DE, dk_DK, it_IT, no_NO, pl_PL, se_SE, pt_PT, cy_GB, el_GR, th_TH, CW)
- Voice file indexing via i8n_voice_map.py
- Voice file indexing via i8n_voice_map.py

@ -12,3 +12,6 @@ mysql-connector-python
cryptography
markdown
pdfkit
gTTS>=2.3.1
# Dependencias del sistema (instalar con apt/dnf/apk, NO con pip):
# ffmpeg - Requerido por TTS para convertir MP3 a WAV (apt install ffmpeg)

@ -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…
Cancel
Save

Powered by TurnKey Linux.