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
Joaquin Madrid Belando 2 weeks ago committed by GitHub
parent 4e0895b9f1
commit aa96b48359
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -36,6 +36,7 @@ This program currently only works with group voice calls.
# Python modules we need # Python modules we need
import sys import sys
import os
from bitarray import bitarray from bitarray import bitarray
from time import time,sleep,perf_counter from time import time,sleep,perf_counter
import importlib.util import importlib.util
@ -52,7 +53,7 @@ from hashlib import blake2b
# Twisted is pretty important, so I keep it separate # Twisted is pretty important, so I keep it separate
from twisted.internet.protocol import Factory, Protocol from twisted.internet.protocol import Factory, Protocol
from twisted.protocols.basic import NetstringReceiver from twisted.protocols.basic import NetstringReceiver
from twisted.internet import reactor, task from twisted.internet import reactor, task, threads
from twisted.web.server import Site from twisted.web.server import Site
#from spyne import Application #from spyne import Application
@ -65,7 +66,7 @@ from hblink import HBSYSTEM, OPENBRIDGE, systems, hblink_handler, reportFactory,
from dmr_utils3.utils import bytes_3, int_id, get_alias, bytes_4 from dmr_utils3.utils import bytes_3, int_id, get_alias, bytes_4
from dmr_utils3 import decode, bptc, const from dmr_utils3 import decode, bptc, const
import config import config
from config import acl_build from config import acl_build, reload_voice_config
import log import log
from const import * from const import *
from mk_voice import pkt_gen from mk_voice import pkt_gen
@ -74,12 +75,13 @@ from utils import load_json, save_json
#Read voices #Read voices
from read_ambe import readAMBE from read_ambe import readAMBE
from tts_engine import ensure_tts_ambe
#Remap some words for certain languages #Remap some words for certain languages
from i8n_voice_map import voiceMap from i8n_voice_map import voiceMap
# Stuff for socket reporting # Stuff for socket reporting
import pickle import pickle
# REMOVE LATER from datetime import datetime from datetime import datetime
# The module needs logging, but handlers, etc. are controlled by the parent # The module needs logging, but handlers, etc. are controlled by the parent
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -99,8 +101,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' __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' __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' __license__ = 'GNU GPLv3'
__maintainer__ = 'Esteban Mackay, HP3ICC' __maintainer__ = 'Esteban Mackay, HP3ICC - Joaquin Madrid, EA5GVK'
__email__ = 'setcom40@gmail.com' __email__ = 'setcom40@gmail.com - ea5gvk@gmail.com'
#Set header bits #Set header bits
#used for slot rewrite and type rewrite #used for slot rewrite and type rewrite
@ -334,7 +336,7 @@ def make_single_reflector(_tgid,_tmout,_sourcesystem):
def remove_bridge_system(system): def remove_bridge_system(system):
_bridgestemp = {} _bridgestemp = {}
_bridgetemp = {} _bridgetemp = {}
for _bridge in BRIDGES: for _bridge in list(BRIDGES):
for _bridgesystem in BRIDGES[_bridge]: for _bridgesystem in BRIDGES[_bridge]:
if _bridgesystem['SYSTEM'] != system: if _bridgesystem['SYSTEM'] != system:
if _bridge not in _bridgestemp: if _bridge not in _bridgestemp:
@ -349,7 +351,7 @@ def remove_bridge_system(system):
def deactivate_all_dynamic_bridges(system_name): def deactivate_all_dynamic_bridges(system_name):
"""Desactiva todos los bridges dinámicos (no estáticos, no reflectores) de un sistema.""" """Desactiva todos los bridges dinámicos (no estáticos, no reflectores) de un sistema."""
for _bridge in BRIDGES: for _bridge in list(BRIDGES):
if _bridge[0:1] == '#': # Saltar reflectores if _bridge[0:1] == '#': # Saltar reflectores
continue continue
for _sys_entry in BRIDGES[_bridge]: for _sys_entry in BRIDGES[_bridge]:
@ -368,7 +370,9 @@ def rule_timer_loop():
# Mantener registro de bridges dinámicos activos por sistema # Mantener registro de bridges dinámicos activos por sistema
_active_dynamic_bridges = {} _active_dynamic_bridges = {}
for _bridge in BRIDGES: _debug_msgs = []
for _bridge in list(BRIDGES):
_bridge_used = False _bridge_used = False
### MODIFIED: Detect special TGIDs (9990-9999) to exclude them from infinite timer logic ### MODIFIED: Detect special TGIDs (9990-9999) to exclude them from infinite timer logic
@ -400,9 +404,9 @@ def rule_timer_loop():
if _system['SYSTEM'] not in _active_dynamic_bridges: if _system['SYSTEM'] not in _active_dynamic_bridges:
_active_dynamic_bridges[_system['SYSTEM']] = [] _active_dynamic_bridges[_system['SYSTEM']] = []
_active_dynamic_bridges[_system['SYSTEM']].append((_bridge, _system)) _active_dynamic_bridges[_system['SYSTEM']].append((_bridge, _system))
logger.debug('(ROUTER) Conference Bridge ACTIVE (INFINITE TIMER): System: %s Bridge: %s, TS: %s, TGID: %s', _system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID'])) _debug_msgs.append('(ROUTER) Conference Bridge ACTIVE (INFINITE TIMER): System: %s Bridge: %s, TS: %s, TGID: %s' % (_system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID'])))
else: else:
logger.debug('(ROUTER) Conference Bridge INACTIVE (no change): System: %s Bridge: %s, TS: %s, TGID: %s', _system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID'])) _debug_msgs.append('(ROUTER) Conference Bridge INACTIVE (no change): System: %s Bridge: %s, TS: %s, TGID: %s' % (_system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID'])))
elif _system['TO_TYPE'] == 'OFF': elif _system['TO_TYPE'] == 'OFF':
if _system['ACTIVE'] == False: if _system['ACTIVE'] == False:
# Activar inmediatamente sin timer # Activar inmediatamente sin timer
@ -411,7 +415,7 @@ def rule_timer_loop():
logger.info('(ROUTER) Conference Bridge ACTIVATED (NO TIMEOUT): System: %s, Bridge: %s, TS: %s, TGID: %s', _system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID'])) logger.info('(ROUTER) Conference Bridge ACTIVATED (NO TIMEOUT): System: %s, Bridge: %s, TS: %s, TGID: %s', _system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID']))
else: else:
_bridge_used = True _bridge_used = True
logger.debug('(ROUTER) Conference Bridge ACTIVE (no change): System: %s Bridge: %s, TS: %s, TGID: %s', _system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID'])) _debug_msgs.append('(ROUTER) Conference Bridge ACTIVE (no change): System: %s Bridge: %s, TS: %s, TGID: %s' % (_system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID'])))
else: else:
# COMPORTAMIENTO ORIGINAL (SINGLE MODE ACTIVADO o bridges estáticos o TGIDs especiales) # COMPORTAMIENTO ORIGINAL (SINGLE MODE ACTIVADO o bridges estáticos o TGIDs especiales)
if _system['TO_TYPE'] == 'ON': if _system['TO_TYPE'] == 'ON':
@ -427,7 +431,7 @@ def rule_timer_loop():
_bridge_used = True _bridge_used = True
logger.info('(ROUTER) Conference Bridge ACTIVE (ON timer running): System: %s Bridge: %s, TS: %s, TGID: %s, Timeout in: %.2fs,', _system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID']), timeout_in) logger.info('(ROUTER) Conference Bridge ACTIVE (ON timer running): System: %s Bridge: %s, TS: %s, TGID: %s, Timeout in: %.2fs,', _system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID']), timeout_in)
elif _system['ACTIVE'] == False: elif _system['ACTIVE'] == False:
logger.debug('(ROUTER) Conference Bridge INACTIVE (no change): System: %s Bridge: %s, TS: %s, TGID: %s', _system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID'])) _debug_msgs.append('(ROUTER) Conference Bridge INACTIVE (no change): System: %s Bridge: %s, TS: %s, TGID: %s' % (_system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID'])))
elif _system['TO_TYPE'] == 'OFF': elif _system['TO_TYPE'] == 'OFF':
if _system['ACTIVE'] == False: if _system['ACTIVE'] == False:
if _system['TIMER'] < _now: if _system['TIMER'] < _now:
@ -440,30 +444,33 @@ def rule_timer_loop():
logger.info('(ROUTER) Conference Bridge INACTIVE (OFF timer running): System: %s Bridge: %s, TS: %s, TGID: %s, Timeout in: %.2fs,', _system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID']), timeout_in) logger.info('(ROUTER) Conference Bridge INACTIVE (OFF timer running): System: %s Bridge: %s, TS: %s, TGID: %s, Timeout in: %.2fs,', _system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID']), timeout_in)
elif _system['ACTIVE'] == True: elif _system['ACTIVE'] == True:
_bridge_used = True _bridge_used = True
logger.debug('(ROUTER) Conference Bridge ACTIVE (no change): System: %s Bridge: %s, TS: %s, TGID: %s', _system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID'])) _debug_msgs.append('(ROUTER) Conference Bridge ACTIVE (no change): System: %s Bridge: %s, TS: %s, TGID: %s' % (_system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID'])))
else: else:
if _system['SYSTEM'][0:3] != 'OBP': if _system['SYSTEM'][0:3] != 'OBP':
_bridge_used = True _bridge_used = True
elif _system['SYSTEM'][0:3] == 'OBP' and _system['TO_TYPE'] == 'STAT': elif _system['SYSTEM'][0:3] == 'OBP' and _system['TO_TYPE'] == 'STAT':
_bridge_used = True _bridge_used = True
logger.debug('(ROUTER) Conference Bridge NO ACTION: System: %s, Bridge: %s, TS: %s, TGID: %s', _system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID'])) _debug_msgs.append('(ROUTER) Conference Bridge NO ACTION: System: %s, Bridge: %s, TS: %s, TGID: %s' % (_system['SYSTEM'], _bridge, _system['TS'], int_id(_system['TGID'])))
if _bridge_used == False: if _bridge_used == False:
_remove_bridges.append(_bridge) _remove_bridges.append(_bridge)
if _debug_msgs:
logger.debug('\n'.join(_debug_msgs))
for _bridgerem in _remove_bridges: for _bridgerem in _remove_bridges:
del BRIDGES[_bridgerem] del BRIDGES[_bridgerem]
logger.debug('(ROUTER) Unused conference bridge %s removed',_bridgerem) logger.debug('(ROUTER) Unused conference bridge %s removed',_bridgerem)
if CONFIG['REPORTS']['REPORT']: if CONFIG['REPORTS']['REPORT']:
report_server.send_clients(b'bridge updated') reactor.callFromThread(report_server.send_clients, b'bridge updated')
### END MODIFIED ### ### END MODIFIED ###
def statTrimmer(): def statTrimmer():
logger.debug('(ROUTER) STAT trimmer loop started') logger.debug('(ROUTER) STAT trimmer loop started')
_remove_bridges = deque() _remove_bridges = deque()
for _bridge in BRIDGES: for _bridge in list(BRIDGES):
_bridge_stat = False _bridge_stat = False
_in_use = False _in_use = False
for _system in BRIDGES[_bridge]: for _system in BRIDGES[_bridge]:
@ -496,8 +503,8 @@ def bridgeDebug():
bridgeroll = 0 bridgeroll = 0
dialroll = 0 dialroll = 0
activeroll = 0 activeroll = 0
for _bridge in BRIDGES: for _bridge in list(BRIDGES):
for enabled_system in BRIDGES[_bridge]: for enabled_system in BRIDGES.get(_bridge, []):
if enabled_system['SYSTEM'] == system: if enabled_system['SYSTEM'] == system:
bridgeroll += 1 bridgeroll += 1
if enabled_system['ACTIVE']: if enabled_system['ACTIVE']:
@ -515,8 +522,8 @@ def bridgeDebug():
if dialroll > 1 and CONFIG['SYSTEMS'][system]['MODE'] == 'MASTER': if dialroll > 1 and CONFIG['SYSTEMS'][system]['MODE'] == 'MASTER':
logger.warning('(BRIDGEDEBUG) system %s has more than one active dial bridge (%s) - fixing',system, dialroll) logger.warning('(BRIDGEDEBUG) system %s has more than one active dial bridge (%s) - fixing',system, dialroll)
times = {} times = {}
for _bridge in BRIDGES: for _bridge in list(BRIDGES):
for enabled_system in BRIDGES[_bridge]: for enabled_system in BRIDGES.get(_bridge, []):
if enabled_system['ACTIVE'] and _bridge and _bridge[0:1] == '#': if enabled_system['ACTIVE'] and _bridge and _bridge[0:1] == '#':
times[enabled_system['TIMER']] = _bridge times[enabled_system['TIMER']] = _bridge
ordered = sorted(times.keys()) ordered = sorted(times.keys())
@ -709,13 +716,16 @@ def sendSpeech(self,speech):
_nine = bytes_3(9) _nine = bytes_3(9)
_source_id = bytes_3(5000) _source_id = bytes_3(5000)
_slot = systems[system].STATUS[2] _slot = systems[system].STATUS[2]
_next_time = time()
while True: while True:
try: try:
pkt = next(speech) pkt = next(speech)
except StopIteration: except StopIteration:
break break
#Packet every 60ms _next_time += 0.058
sleep(0.058) _delay = _next_time - time()
if _delay > 0.001:
sleep(_delay)
reactor.callFromThread(sendVoicePacket,self,pkt,_source_id,_nine,_slot) reactor.callFromThread(sendVoicePacket,self,pkt,_source_id,_nine,_slot)
logger.debug('(%s) Sendspeech thread ended',self._system) logger.debug('(%s) Sendspeech thread ended',self._system)
@ -747,45 +757,66 @@ def disconnectedVoice(system):
sleep(1) sleep(1)
_slot = systems[system].STATUS[2] _slot = systems[system].STATUS[2]
_next_time = time()
while True: while True:
try: try:
pkt = next(speech) pkt = next(speech)
except StopIteration: except StopIteration:
break break
#Packet every 60ms _next_time += 0.058
sleep(0.058) _delay = _next_time - time()
if _delay > 0.001:
sleep(_delay)
_stream_id = pkt[16:20] _stream_id = pkt[16:20]
_pkt_time = time() _pkt_time = time()
reactor.callFromThread(sendVoicePacket,systems[system],pkt,_source_id,_nine,_slot) reactor.callFromThread(sendVoicePacket,systems[system],pkt,_source_id,_nine,_slot)
logger.debug('(%s) disconnected voice thread end',system) logger.debug('(%s) disconnected voice thread end',system)
def playFileOnRequest(self,fileNumber): def playFileOnRequest(self,fileNumber):
system = self._system system = self._system
_lang = CONFIG['SYSTEMS'][system]['ANNOUNCEMENT_LANGUAGE'] _lang = CONFIG['SYSTEMS'][system]['ANNOUNCEMENT_LANGUAGE']
_nine = bytes_3(9) _nine = bytes_3(9)
_source_id = bytes_3(5000) _source_id = bytes_3(5000)
logger.debug('(%s) Sending contents of AMBE file: %s',system,fileNumber)
_ambe_file = '/{}/ondemand/{}.ambe'.format(_lang, fileNumber)
_full_path = os.path.join('./Audio', _lang, 'ondemand', '{}.ambe'.format(fileNumber))
if not os.path.isfile(_full_path):
logger.warning('(%s) AMBE file not found: %s', system, _full_path)
return
logger.info('(%s) Playing on-demand AMBE file: %s (ID: %s)', system, _full_path, fileNumber)
sleep(1) sleep(1)
_say = [] _say = []
try: try:
_say.append(AMBEobj.readSingleFile(''.join(['/',_lang,'/ondemand/',str(fileNumber),'.ambe']))) _say.append(AMBEobj.readSingleFile(_ambe_file))
except IOError: except Exception as e:
logger.warning('(%s) cannot read file for number %s',system,fileNumber) logger.warning('(%s) Error reading AMBE file %s: %s', system, _full_path, e)
return return
if not _say or not _say[0]:
logger.warning('(%s) AMBE file empty or invalid: %s', system, _full_path)
return
speech = pkt_gen(_source_id, _nine, bytes_4(9), 1, _say) speech = pkt_gen(_source_id, _nine, bytes_4(9), 1, _say)
sleep(1) sleep(1)
_slot = systems[system].STATUS[2] _slot = systems[system].STATUS[2]
_next_time = time()
_pkt_count = 0
while True: while True:
try: try:
pkt = next(speech) pkt = next(speech)
except StopIteration: except StopIteration:
break break
#Packet every 60ms _next_time += 0.058
sleep(0.058) _delay = _next_time - time()
if _delay > 0.001:
sleep(_delay)
_stream_id = pkt[16:20] _stream_id = pkt[16:20]
_pkt_time = time() _pkt_time = time()
reactor.callFromThread(sendVoicePacket,self,pkt,_source_id,_nine,_slot) reactor.callFromThread(sendVoicePacket,self,pkt,_source_id,_nine,_slot)
logger.debug('(%s) Sending AMBE file %s end',system,fileNumber) _pkt_count += 1
logger.info('(%s) On-demand playback complete: %s (%d packets)', system, fileNumber, _pkt_count)
def threadIdent(): def threadIdent():
logger.debug('(IDENT) starting ident thread') logger.debug('(IDENT) starting ident thread')
@ -861,18 +892,629 @@ def ident():
sleep(1) sleep(1)
_slot = systems[system].STATUS[2] _slot = systems[system].STATUS[2]
_next_time = time()
while True: while True:
try: try:
pkt = next(speech) pkt = next(speech)
except StopIteration: except StopIteration:
break break
#Packet every 60ms _next_time += 0.058
sleep(0.058) _delay = _next_time - time()
if _delay > 0.001:
sleep(_delay)
_stream_id = pkt[16:20] _stream_id = pkt[16:20]
_pkt_time = time() _pkt_time = time()
reactor.callFromThread(sendVoicePacket,systems[system],pkt,_source_id,_dst_id,_slot) reactor.callFromThread(sendVoicePacket,systems[system],pkt,_source_id,_dst_id,_slot)
_announcement_last_hour = {1: -1, 2: -1, 3: -1, 4: -1}
_announcement_running = {1: False, 2: False, 3: False, 4: False}
_tts_last_hour = {1: -1, 2: -1, 3: -1, 4: -1}
_tts_running = {1: False, 2: False, 3: False, 4: False}
_voice_cfg_mtime = 0
_voice_cfg_file = ''
_voice_cfg_config_file = ''
_ann_tasks = {}
_tts_tasks = {}
_FRAME_INTERVAL = 0.058
_broadcast_queue = []
_broadcast_active = False
_BROADCAST_GAP = 1.5
def _enqueue_broadcast(_type, _targets, _pkts_by_ts, _source_id, _dst_id, _tg, _num, _label):
global _broadcast_queue, _broadcast_active
_broadcast_queue.append({
'type': _type,
'targets': _targets,
'pkts_by_ts': _pkts_by_ts,
'source_id': _source_id,
'dst_id': _dst_id,
'tg': _tg,
'num': _num,
'label': _label
})
_pos = len(_broadcast_queue)
if _broadcast_active:
logger.info('(%s) Enqueued broadcast (position %s in queue)', _label, _pos)
else:
_start_next_broadcast()
def _start_next_broadcast():
global _broadcast_queue, _broadcast_active
if not _broadcast_queue:
_broadcast_active = False
return
_broadcast_active = True
_item = _broadcast_queue.pop(0)
_type = _item['type']
_label = _item['label']
logger.info('(%s) Starting broadcast from queue (%s remaining)', _label, len(_broadcast_queue))
if _type == 'ann':
reactor.callLater(0.5, _announcementSendBroadcast, _item['targets'], _item['pkts_by_ts'], 0, _item['source_id'], _item['dst_id'], _item['tg'], 0, _item['num'])
elif _type == 'tts':
reactor.callLater(0.5, _ttsSendBroadcast, _item['targets'], _item['pkts_by_ts'], 0, _item['source_id'], _item['dst_id'], _item['tg'], 0, _item['num'])
def _broadcast_finished():
global _broadcast_active
if _broadcast_queue:
logger.info('(QUEUE) Broadcast finished, next in %.1fs (%s queued)', _BROADCAST_GAP, len(_broadcast_queue))
reactor.callLater(_BROADCAST_GAP, _start_next_broadcast)
else:
_broadcast_active = False
logger.info('(QUEUE) Broadcast finished, queue empty')
_RECORDING_MAX_FRAMES = 2750
_recording_state = {
'active': False,
'stream_id': None,
'bursts': bitarray(endian='big'),
'start_time': 0,
'frames': 0,
'rf_src': None
}
def _handleRecording(dmrpkt, _frame_type, _dtype_vseq, _stream_id, pkt_time, _rf_src, _int_dst_id, _slot):
global _recording_state
if not CONFIG['GLOBAL']['RECORDING_ENABLED']:
return
if _int_dst_id != CONFIG['GLOBAL']['RECORDING_TG'] or _slot != CONFIG['GLOBAL']['RECORDING_TIMESLOT']:
return
if _frame_type == HBPF_DATA_SYNC and _dtype_vseq == HBPF_SLT_VHEAD:
if _recording_state['active'] and _recording_state['stream_id'] != _stream_id:
logger.info('(GRABACION) Nueva transmision detectada, guardando grabacion anterior (%d frames)', _recording_state['frames'])
_saveRecording()
_recording_state['active'] = True
_recording_state['stream_id'] = _stream_id
_recording_state['bursts'] = bitarray(endian='big')
_recording_state['start_time'] = pkt_time
_recording_state['frames'] = 0
_recording_state['rf_src'] = _rf_src
logger.info('(GRABACION) Grabacion iniciada - SUB: %s, TG: %s, TS: %s', int_id(_rf_src), _int_dst_id, _slot)
return
if not _recording_state['active'] or _recording_state['stream_id'] != _stream_id:
return
if _frame_type in (HBPF_VOICE, HBPF_VOICE_SYNC):
_bits_data = bitarray(endian='big')
_bits_data.frombytes(dmrpkt)
_recording_state['bursts'].extend(_bits_data[:108])
_recording_state['bursts'].extend(_bits_data[156:264])
_recording_state['frames'] += 1
if _recording_state['frames'] >= _RECORDING_MAX_FRAMES:
logger.info('(GRABACION) Duracion maxima alcanzada (%d frames), guardando', _recording_state['frames'])
_saveRecording()
return
if _frame_type == HBPF_DATA_SYNC and _dtype_vseq == HBPF_SLT_VTERM:
_saveRecording()
return
def _saveRecording():
global _recording_state
if not _recording_state['active'] or _recording_state['frames'] == 0:
_recording_state['active'] = False
_recording_state['stream_id'] = None
logger.warning('(GRABACION) No hay frames grabados, descartando')
return
_lang = CONFIG['GLOBAL']['RECORDING_LANGUAGE']
_file = CONFIG['GLOBAL']['RECORDING_FILE']
_dir = './Audio/{}/ondemand/'.format(_lang)
_path = _dir + _file + '.ambe'
os.makedirs(_dir, exist_ok=True)
with open(_path, 'wb') as f:
f.write(_recording_state['bursts'].tobytes())
_duration = time() - _recording_state['start_time']
logger.info('(GRABACION) Grabacion guardada: %s (%d frames, %.1f segundos, SUB: %s)', _path, _recording_state['frames'], _duration, int_id(_recording_state['rf_src']))
_recording_state['active'] = False
_recording_state['stream_id'] = None
_recording_state['bursts'] = bitarray(endian='big')
_recording_state['frames'] = 0
_recording_state['rf_src'] = None
def _sendFilteredByTG(_sys_obj, pkt, _tg, _ts, _label, _pkt_idx):
_sys_name = _sys_obj._system
_tg_str = str(_tg) if isinstance(_tg, int) else str(int_id(_tg))
_has_active_bridge = False
if _tg_str in BRIDGES:
for _be in BRIDGES[_tg_str]:
if _be['SYSTEM'] == _sys_name and _be['TS'] == _ts and _be['ACTIVE']:
_has_active_bridge = True
break
if _has_active_bridge:
_sys_obj.send_system(pkt)
return -1
else:
return 0
def _announcementSendBroadcast(_targets, _pkts_by_ts, _pkt_idx, _source_id, _dst_id, _tg, _ts_unused, _ann_num=1, _next_time=None):
global _announcement_running
_label = 'LOCUCION' if _ann_num == 1 else 'LOCUCION-{}'.format(_ann_num)
_total_pkts = len(_pkts_by_ts[1])
if _pkt_idx >= _total_pkts:
for _t in _targets:
try:
for _sid in list(_t['sys_obj'].STATUS.keys()):
if _sid not in (1, 2):
del _t['sys_obj'].STATUS[_sid]
except:
pass
_announcement_running[_ann_num] = False
logger.info('(%s) Broadcast complete: %s packets sent to %s targets', _label, _total_pkts, len(_targets))
_broadcast_finished()
return
_now = time()
for _t in _targets:
try:
_sys_obj = _t['sys_obj']
_slot = _t['slot']
_t_ts = _t['ts']
pkt = _pkts_by_ts[_t_ts][_pkt_idx]
_stream_id = pkt[16:20]
if _stream_id not in _sys_obj.STATUS:
_sys_obj.STATUS[_stream_id] = {
'START': _now,
'CONTENTION':False,
'RFS': _source_id,
'TGID': _dst_id,
'LAST': _now
}
_slot['TX_TGID'] = _dst_id
else:
_sys_obj.STATUS[_stream_id]['LAST'] = _now
_slot['TX_TIME'] = _now
_fc = _sendFilteredByTG(_sys_obj, pkt, _tg, _t_ts, _label, _pkt_idx)
if _pkt_idx == 0 and _fc == 0:
logger.debug('(%s) TG %s TS%s not active on %s, skipping', _label, _tg, _t_ts, _t['name'])
except Exception as e:
logger.error('(%s) Error sending packet %s to %s/TS%s: %s', _label, _pkt_idx, _t['name'], _t.get('ts', '?'), e)
_elapsed = time() - _now
if _next_time is None:
_next_time = _now + _FRAME_INTERVAL
else:
_next_time = _next_time + _FRAME_INTERVAL
_delay = max(0.001, _next_time - time())
if _pkt_idx < 3:
logger.debug('(%s) Packet %s/%s broadcast to %s targets (proc: %.1fms, delay: %.1fms)', _label, _pkt_idx + 1, _total_pkts, len(_targets), _elapsed * 1000, _delay * 1000)
reactor.callLater(_delay, _announcementSendBroadcast, _targets, _pkts_by_ts, _pkt_idx + 1, _source_id, _dst_id, _tg, _ts_unused, _ann_num, _next_time)
def scheduledAnnouncement(_ann_num=1):
global _announcement_last_hour, _announcement_running
_prefix = 'ANNOUNCEMENT' if _ann_num == 1 else 'ANNOUNCEMENT{}'.format(_ann_num)
_label = 'LOCUCION' if _ann_num == 1 else 'LOCUCION-{}'.format(_ann_num)
if not CONFIG['GLOBAL']['{}_ENABLED'.format(_prefix)]:
return
if _announcement_running[_ann_num]:
logger.debug('(%s) Previous announcement still running, skipping', _label)
return
_mode = CONFIG['GLOBAL']['{}_MODE'.format(_prefix)]
if _mode == 'hourly':
_now = datetime.now()
if _now.minute != 0:
return
if _now.hour == _announcement_last_hour[_ann_num]:
return
_announcement_last_hour[_ann_num] = _now.hour
_file = CONFIG['GLOBAL']['{}_FILE'.format(_prefix)]
_tg = CONFIG['GLOBAL']['{}_TG'.format(_prefix)]
_lang = CONFIG['GLOBAL']['{}_LANGUAGE'.format(_prefix)]
_dst_id = bytes_3(_tg)
_source_id = bytes_3(5000)
_peer_id = CONFIG['GLOBAL']['SERVER_ID']
logger.info('(%s) Playing file: %s to TG %s (both TS, mode: %s, lang: %s)', _label, _file, _tg, _mode, _lang)
_say = []
try:
_say.append(AMBEobj.readSingleFile(''.join(['/', _lang, '/ondemand/', str(_file), '.ambe'])))
except IOError:
logger.warning('(%s) Cannot read AMBE file: Audio/%s/ondemand/%s.ambe', _label, _lang, _file)
return
except Exception as e:
logger.error('(%s) Error reading AMBE file: %s', _label, e)
return
logger.debug('(%s) AMBE file loaded, %s words', _label, len(_say))
_tg_str = str(_tg)
_excluded = ['ECHO', 'D-APRS']
_targets = []
for _sn in list(systems.keys()):
if _sn in _excluded or any(_sn.startswith(ex + '-') for ex in _excluded):
continue
if _sn not in CONFIG['SYSTEMS']:
continue
if CONFIG['SYSTEMS'][_sn]['MODE'] != 'MASTER':
continue
if 'PEERS' not in CONFIG['SYSTEMS'][_sn]:
continue
_has_peers = False
try:
for _pid in CONFIG['SYSTEMS'][_sn]['PEERS']:
if CONFIG['SYSTEMS'][_sn]['PEERS'][_pid]['CALLSIGN']:
_has_peers = True
break
except (KeyError, TypeError, RuntimeError):
continue
if not _has_peers:
continue
if _sn not in systems:
continue
_active_slots = []
if _tg_str in BRIDGES:
for _be in BRIDGES[_tg_str]:
if _be['SYSTEM'] == _sn and _be['ACTIVE'] and _be['TS'] not in _active_slots:
_active_slots.append(_be['TS'])
if not _active_slots:
continue
for _ts in _active_slots:
_slot_index = 2 if _ts == 2 else 1
_slot = systems[_sn].STATUS[_slot_index]
if (_slot['RX_TYPE'] != HBPF_SLT_VTERM) or (_slot['TX_TYPE'] != HBPF_SLT_VTERM):
logger.debug('(%s) System %s TS%s busy, skipping', _label, _sn, _ts)
continue
_targets.append({
'sys_obj': systems[_sn],
'name': _sn,
'slot': _slot,
'ts': _ts
})
if not _targets:
logger.info('(%s) No systems with active bridge for TG %s to send to', _label, _tg)
return
_pkts_by_ts = {
1: list(pkt_gen(_source_id, _dst_id, _peer_id, 0, _say)),
2: list(pkt_gen(_source_id, _dst_id, _peer_id, 1, _say)),
}
_ts1_count = sum(1 for t in _targets if t['ts'] == 1)
_ts2_count = sum(1 for t in _targets if t['ts'] == 2)
_sys_names = ', '.join(['{}/TS{}'.format(t['name'], t['ts']) for t in _targets[:8]])
if len(_targets) > 8:
_sys_names += ', ... +{}'.format(len(_targets) - 8)
logger.info('(%s) Broadcasting %s packets to %s targets (TS1:%s TS2:%s): %s',
_label, len(_pkts_by_ts[1]), len(_targets), _ts1_count, _ts2_count, _sys_names)
_announcement_running[_ann_num] = True
_enqueue_broadcast('ann', _targets, _pkts_by_ts, _source_id, _dst_id, _tg, _ann_num, _label)
def _checkVoiceConfigReload():
global _voice_cfg_mtime, _ann_tasks, _tts_tasks
if not _voice_cfg_file or not os.path.isfile(_voice_cfg_file):
return
try:
_current_mtime = os.path.getmtime(_voice_cfg_file)
except OSError:
return
if _current_mtime == _voice_cfg_mtime:
return
_voice_cfg_mtime = _current_mtime
logger.info('(VOICE-RELOAD) Detectado cambio en voice.cfg, recargando configuracion...')
if not reload_voice_config(CONFIG, _voice_cfg_config_file):
logger.error('(VOICE-RELOAD) Error recargando voice.cfg')
return
for _ann_num in range(1, 5):
_prefix = 'ANNOUNCEMENT' if _ann_num == 1 else 'ANNOUNCEMENT{}'.format(_ann_num)
_label = 'LOCUCION' if _ann_num == 1 else 'LOCUCION-{}'.format(_ann_num)
_enabled = CONFIG['GLOBAL'].get('{}_ENABLED'.format(_prefix), False)
if _ann_num in _ann_tasks and _ann_tasks[_ann_num].running:
_ann_tasks[_ann_num].stop()
logger.info('(VOICE-RELOAD) %s detenida', _label)
del _ann_tasks[_ann_num]
if _enabled:
_ann_mode = CONFIG['GLOBAL']['{}_MODE'.format(_prefix)]
if _ann_mode == 'hourly':
_ann_check_interval = 30
else:
_ann_check_interval = CONFIG['GLOBAL']['{}_INTERVAL'.format(_prefix)]
_ann_task = task.LoopingCall(scheduledAnnouncement, _ann_num)
_ann_def = _ann_task.start(_ann_check_interval, now=False)
_ann_def.addErrback(loopingErrHandle)
_ann_tasks[_ann_num] = _ann_task
logger.info('(VOICE-RELOAD) %s activada - mode: %s, file: %s, TG: %s, TS: auto',
_label, _ann_mode,
CONFIG['GLOBAL']['{}_FILE'.format(_prefix)],
CONFIG['GLOBAL']['{}_TG'.format(_prefix)])
for _tts_num in range(1, 5):
_prefix = 'TTS_ANNOUNCEMENT{}'.format(_tts_num)
_label = 'TTS-{}'.format(_tts_num)
_enabled = CONFIG['GLOBAL'].get('{}_ENABLED'.format(_prefix), False)
if _tts_num in _tts_tasks and _tts_tasks[_tts_num].running:
_tts_tasks[_tts_num].stop()
logger.info('(VOICE-RELOAD) %s detenida', _label)
del _tts_tasks[_tts_num]
if _enabled:
_tts_mode = CONFIG['GLOBAL']['{}_MODE'.format(_prefix)]
if _tts_mode == 'hourly':
_tts_check_interval = 30
else:
_tts_check_interval = CONFIG['GLOBAL']['{}_INTERVAL'.format(_prefix)]
_tts_task = task.LoopingCall(scheduledTTSAnnouncement, _tts_num)
_tts_def = _tts_task.start(_tts_check_interval, now=False)
_tts_def.addErrback(loopingErrHandle)
_tts_tasks[_tts_num] = _tts_task
logger.info('(VOICE-RELOAD) %s activada - mode: %s, file: %s, TG: %s, TS: auto',
_label, _tts_mode,
CONFIG['GLOBAL']['{}_FILE'.format(_prefix)],
CONFIG['GLOBAL']['{}_TG'.format(_prefix)])
logger.info('(VOICE-RELOAD) Recarga de voice.cfg completada')
def scheduledTTSAnnouncement(_tts_num=1):
global _tts_last_hour, _tts_running
_prefix = 'TTS_ANNOUNCEMENT{}'.format(_tts_num)
_label = 'TTS-{}'.format(_tts_num)
if not CONFIG['GLOBAL'].get('{}_ENABLED'.format(_prefix), False):
return
if _tts_running[_tts_num]:
logger.debug('(%s) Previous TTS announcement still running, skipping', _label)
return
_mode = CONFIG['GLOBAL']['{}_MODE'.format(_prefix)]
if _mode == 'hourly':
_now = datetime.now()
if _now.minute != 0:
return
if _now.hour == _tts_last_hour[_tts_num]:
return
_tts_last_hour[_tts_num] = _now.hour
_file = CONFIG['GLOBAL']['{}_FILE'.format(_prefix)]
_tg = CONFIG['GLOBAL']['{}_TG'.format(_prefix)]
_lang = CONFIG['GLOBAL']['{}_LANGUAGE'.format(_prefix)]
_tts_running[_tts_num] = True
logger.info('(%s) Iniciando conversion TTS en hilo separado para %s', _label, _file)
d = threads.deferToThread(ensure_tts_ambe, CONFIG, _tts_num)
d.addCallback(_ttsConversionDone, _tts_num, _file, _tg, _lang, _mode, _label)
d.addErrback(_ttsConversionError, _tts_num, _label)
def _ttsConversionDone(_ambe_path, _tts_num, _file, _tg, _lang, _mode, _label):
global _tts_running
if not _ambe_path:
_tts_running[_tts_num] = False
logger.warning('(%s) No AMBE file available for TTS announcement %s', _label, _file)
return
logger.info('(%s) Playing TTS file: %s to TG %s (both TS, mode: %s, lang: %s)', _label, _file, _tg, _mode, _lang)
_dst_id = bytes_3(_tg)
_source_id = bytes_3(5000)
_peer_id = CONFIG['GLOBAL']['SERVER_ID']
_say = []
try:
_relative_path = _ambe_path
if _relative_path.startswith('./Audio/'):
_relative_path = _relative_path[len('./Audio'):]
elif _relative_path.startswith('Audio/'):
_relative_path = '/' + _relative_path[len('Audio'):]
_say.append(AMBEobj.readSingleFile(_relative_path))
except IOError:
_tts_running[_tts_num] = False
logger.warning('(%s) Cannot read AMBE file: %s', _label, _ambe_path)
return
except Exception as e:
_tts_running[_tts_num] = False
logger.error('(%s) Error reading AMBE file: %s', _label, e)
return
logger.debug('(%s) AMBE file loaded, %s words', _label, len(_say))
_tg_str = str(_tg)
_excluded = ['ECHO', 'D-APRS']
_targets = []
for _sn in list(systems.keys()):
if _sn in _excluded or any(_sn.startswith(ex + '-') for ex in _excluded):
continue
if _sn not in CONFIG['SYSTEMS']:
continue
if CONFIG['SYSTEMS'][_sn]['MODE'] != 'MASTER':
continue
if 'PEERS' not in CONFIG['SYSTEMS'][_sn]:
continue
_has_peers = False
try:
for _pid in CONFIG['SYSTEMS'][_sn]['PEERS']:
if CONFIG['SYSTEMS'][_sn]['PEERS'][_pid]['CALLSIGN']:
_has_peers = True
break
except (KeyError, TypeError, RuntimeError):
continue
if not _has_peers:
continue
if _sn not in systems:
continue
_active_slots = []
if _tg_str in BRIDGES:
for _be in BRIDGES[_tg_str]:
if _be['SYSTEM'] == _sn and _be['ACTIVE'] and _be['TS'] not in _active_slots:
_active_slots.append(_be['TS'])
if not _active_slots:
continue
for _ts in _active_slots:
_slot_index = 2 if _ts == 2 else 1
_slot = systems[_sn].STATUS[_slot_index]
if (_slot['RX_TYPE'] != HBPF_SLT_VTERM) or (_slot['TX_TYPE'] != HBPF_SLT_VTERM):
logger.debug('(%s) System %s TS%s busy, skipping', _label, _sn, _ts)
continue
_targets.append({
'sys_obj': systems[_sn],
'name': _sn,
'slot': _slot,
'ts': _ts
})
logger.debug('(%s) System %s added for TG %s TS%s', _label, _sn, _tg, _ts)
if not _targets:
_tts_running[_tts_num] = False
logger.info('(%s) No systems with active bridge for TG %s to send to', _label, _tg)
return
_pkts_by_ts = {
1: list(pkt_gen(_source_id, _dst_id, _peer_id, 0, _say)),
2: list(pkt_gen(_source_id, _dst_id, _peer_id, 1, _say)),
}
_ts1_count = sum(1 for t in _targets if t['ts'] == 1)
_ts2_count = sum(1 for t in _targets if t['ts'] == 2)
_sys_names = ', '.join(['{}/TS{}'.format(t['name'], t['ts']) for t in _targets[:8]])
if len(_targets) > 8:
_sys_names += ', ... +{}'.format(len(_targets) - 8)
logger.info('(%s) Broadcasting %s packets to %s targets (TS1:%s TS2:%s): %s',
_label, len(_pkts_by_ts[1]), len(_targets), _ts1_count, _ts2_count, _sys_names)
_enqueue_broadcast('tts', _targets, _pkts_by_ts, _source_id, _dst_id, _tg, _tts_num, _label)
def _ttsConversionError(failure, _tts_num, _label):
global _tts_running
_tts_running[_tts_num] = False
logger.error('(%s) Error en conversion TTS: %s', _label, failure.getErrorMessage())
def _ttsSendBroadcast(_targets, _pkts_by_ts, _pkt_idx, _source_id, _dst_id, _tg, _ts_unused, _tts_num=1, _next_time=None):
global _tts_running
_label = 'TTS-{}'.format(_tts_num)
_total_pkts = len(_pkts_by_ts[1])
if _pkt_idx >= _total_pkts:
for _t in _targets:
try:
for _sid in list(_t['sys_obj'].STATUS.keys()):
if _sid not in (1, 2):
del _t['sys_obj'].STATUS[_sid]
except:
pass
_tts_running[_tts_num] = False
logger.info('(%s) Broadcast complete: %s packets sent to %s targets', _label, _total_pkts, len(_targets))
_broadcast_finished()
return
_now = time()
for _t in _targets:
try:
_sys_obj = _t['sys_obj']
_slot = _t['slot']
_t_ts = _t['ts']
pkt = _pkts_by_ts[_t_ts][_pkt_idx]
_stream_id = pkt[16:20]
if _stream_id not in _sys_obj.STATUS:
_sys_obj.STATUS[_stream_id] = {
'START': _now,
'CONTENTION':False,
'RFS': _source_id,
'TGID': _dst_id,
'LAST': _now
}
_slot['TX_TGID'] = _dst_id
else:
_sys_obj.STATUS[_stream_id]['LAST'] = _now
_slot['TX_TIME'] = _now
_fc = _sendFilteredByTG(_sys_obj, pkt, _tg, _t_ts, _label, _pkt_idx)
if _pkt_idx == 0 and _fc == 0:
logger.debug('(%s) TG %s TS%s not active on %s, skipping', _label, _tg, _t_ts, _t['name'])
except Exception as e:
logger.error('(%s) Error sending packet %s to %s/TS%s: %s', _label, _pkt_idx, _t['name'], _t.get('ts', '?'), e)
_elapsed = time() - _now
if _next_time is None:
_next_time = _now + _FRAME_INTERVAL
else:
_next_time = _next_time + _FRAME_INTERVAL
_delay = max(0.001, _next_time - time())
if _pkt_idx < 3:
logger.debug('(%s) Packet %s/%s broadcast to %s targets (proc: %.1fms, delay: %.1fms)', _label, _pkt_idx + 1, _total_pkts, len(_targets), _elapsed * 1000, _delay * 1000)
reactor.callLater(_delay, _ttsSendBroadcast, _targets, _pkts_by_ts, _pkt_idx + 1, _source_id, _dst_id, _tg, _ts_unused, _tts_num, _next_time)
def bridge_reset(): def bridge_reset():
logger.debug('(BRIDGERESET) Running bridge resetter') logger.debug('(BRIDGERESET) Running bridge resetter')
for _system in CONFIG['SYSTEMS']: for _system in CONFIG['SYSTEMS']:
@ -2505,6 +3147,7 @@ class routerHBP(HBSYSTEM):
self._system, int_id(_stream_id), get_alias(_rf_src, subscriber_ids), int_id(_rf_src), get_alias(_peer_id, peer_ids), int_id(_peer_id), get_alias(_dst_id, talkgroup_ids), int_id(_dst_id), _slot) self._system, int_id(_stream_id), get_alias(_rf_src, subscriber_ids), int_id(_rf_src), get_alias(_peer_id, peer_ids), int_id(_peer_id), get_alias(_dst_id, talkgroup_ids), int_id(_dst_id), _slot)
if CONFIG['REPORTS']['REPORT']: if CONFIG['REPORTS']['REPORT']:
self._report.send_bridgeEvent('GROUP VOICE,START,RX,{},{},{},{},{},{}'.format(self._system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _slot, int_id(_dst_id)).encode(encoding='utf-8', errors='ignore')) self._report.send_bridgeEvent('GROUP VOICE,START,RX,{},{},{},{},{},{}'.format(self._system, int_id(_stream_id), int_id(_peer_id), int_id(_rf_src), _slot, int_id(_dst_id)).encode(encoding='utf-8', errors='ignore'))
else: else:
logger.info('(%s) *VCSBK* STREAM ID: %s SUB: %s (%s) PEER: %s (%s) TGID %s (%s), TS %s _dtype_vseq: %s', logger.info('(%s) *VCSBK* STREAM ID: %s SUB: %s (%s) PEER: %s (%s) TGID %s (%s), TS %s _dtype_vseq: %s',
self._system, int_id(_stream_id), get_alias(_rf_src, subscriber_ids), int_id(_rf_src), get_alias(_peer_id, peer_ids), int_id(_peer_id), get_alias(_dst_id, talkgroup_ids), int_id(_dst_id), _slot, _dtype_vseq) self._system, int_id(_stream_id), get_alias(_rf_src, subscriber_ids), int_id(_rf_src), get_alias(_peer_id, peer_ids), int_id(_peer_id), get_alias(_dst_id, talkgroup_ids), int_id(_dst_id), _slot, _dtype_vseq)
@ -2609,6 +3252,8 @@ class routerHBP(HBSYSTEM):
self.STATUS[_slot]['lastSeq'] = _seq self.STATUS[_slot]['lastSeq'] = _seq
#Save this packet #Save this packet
self.STATUS[_slot]['lastData'] = _data self.STATUS[_slot]['lastData'] = _data
_handleRecording(dmrpkt, _frame_type, _dtype_vseq, _stream_id, pkt_time, _rf_src, _int_dst_id, _slot)
### MODIFIED: Prioritize routing for the TGID that just created a bridge ### MODIFIED: Prioritize routing for the TGID that just created a bridge
_sysIgnore = deque() _sysIgnore = deque()
@ -2841,6 +3486,7 @@ if __name__ == '__main__':
if cli_args.LOG_LEVEL: if cli_args.LOG_LEVEL:
CONFIG['LOGGER']['LOG_LEVEL'] = cli_args.LOG_LEVEL CONFIG['LOGGER']['LOG_LEVEL'] = cli_args.LOG_LEVEL
logger = log.config_logging(CONFIG['LOGGER']) logger = log.config_logging(CONFIG['LOGGER'])
logger.info('\n\nCopyright (c) 2026 Joaquin Madrid Belando, EA5GVK ea5gvk@gmail.com')
logger.info('\n\nCopyright (c) 2024-2026 Esteban Mackay, HP3ICC setcom40@gmail.com') logger.info('\n\nCopyright (c) 2024-2026 Esteban Mackay, HP3ICC setcom40@gmail.com')
logger.info('\n\nCopyright (c) 2020-2023 Simon G7RZU simon@gb7fr.org.uk') logger.info('\n\nCopyright (c) 2020-2023 Simon G7RZU simon@gb7fr.org.uk')
logger.info('Copyright (c) 2013, 2014, 2015, 2016, 2018, 2019\n\tThe Regents of the K0USY Group. All rights reserved.\n') logger.info('Copyright (c) 2013, 2014, 2015, 2016, 2018, 2019\n\tThe Regents of the K0USY Group. All rights reserved.\n')
@ -3069,7 +3715,9 @@ if __name__ == '__main__':
# logger.info('(API) API not started') # logger.info('(API) API not started')
# Initialize the rule timer -- this if for user activated stuff # Initialize the rule timer -- this if for user activated stuff
rule_timer_task = task.LoopingCall(rule_timer_loop) def _rule_timer_in_thread():
return threads.deferToThread(rule_timer_loop)
rule_timer_task = task.LoopingCall(_rule_timer_in_thread)
rule_timer = rule_timer_task.start(52) rule_timer = rule_timer_task.start(52)
rule_timer.addErrback(loopingErrHandle) rule_timer.addErrback(loopingErrHandle)
@ -3102,12 +3750,16 @@ if __name__ == '__main__':
#STAT trimmer - once every 5 mins (roughly - shifted so all timed tasks don't run at once #STAT trimmer - once every 5 mins (roughly - shifted so all timed tasks don't run at once
if CONFIG['GLOBAL']['GEN_STAT_BRIDGES']: if CONFIG['GLOBAL']['GEN_STAT_BRIDGES']:
stat_trimmer_task = task.LoopingCall(statTrimmer) def _stat_trimmer_in_thread():
return threads.deferToThread(statTrimmer)
stat_trimmer_task = task.LoopingCall(_stat_trimmer_in_thread)
stat_trimmer = stat_trimmer_task.start(303)#3600 stat_trimmer = stat_trimmer_task.start(303)#3600
stat_trimmer.addErrback(loopingErrHandle) stat_trimmer.addErrback(loopingErrHandle)
#KA Reporting #KA Reporting
ka_task = task.LoopingCall(kaReporting) def _ka_reporting_in_thread():
return threads.deferToThread(kaReporting)
ka_task = task.LoopingCall(_ka_reporting_in_thread)
ka = ka_task.start(60) ka = ka_task.start(60)
ka.addErrback(loopingErrHandle) ka.addErrback(loopingErrHandle)
@ -3126,7 +3778,64 @@ if __name__ == '__main__':
killserver_task = task.LoopingCall(kill_server) killserver_task = task.LoopingCall(kill_server)
killserver = killserver_task.start(5) killserver = killserver_task.start(5)
killserver.addErrback(loopingErrHandle) killserver.addErrback(loopingErrHandle)
for _ann_num in range(1, 5):
_prefix = 'ANNOUNCEMENT' if _ann_num == 1 else 'ANNOUNCEMENT{}'.format(_ann_num)
_label = 'LOCUCION' if _ann_num == 1 else 'LOCUCION-{}'.format(_ann_num)
if CONFIG['GLOBAL']['{}_ENABLED'.format(_prefix)]:
_ann_mode = CONFIG['GLOBAL']['{}_MODE'.format(_prefix)]
if _ann_mode == 'hourly':
_ann_check_interval = 30
else:
_ann_check_interval = CONFIG['GLOBAL']['{}_INTERVAL'.format(_prefix)]
_ann_task = task.LoopingCall(scheduledAnnouncement, _ann_num)
_ann_def = _ann_task.start(_ann_check_interval, now=False)
_ann_def.addErrback(loopingErrHandle)
_ann_tasks[_ann_num] = _ann_task
logger.info('(%s) Scheduled announcements enabled - mode: %s, file: %s, TG: %s, TS: auto, lang: %s',
_label,
_ann_mode,
CONFIG['GLOBAL']['{}_FILE'.format(_prefix)],
CONFIG['GLOBAL']['{}_TG'.format(_prefix)],
CONFIG['GLOBAL']['{}_LANGUAGE'.format(_prefix)])
if _ann_mode == 'interval':
logger.info('(%s) Interval: every %s seconds', _label, _ann_check_interval)
for _tts_num in range(1, 5):
_prefix = 'TTS_ANNOUNCEMENT{}'.format(_tts_num)
_label = 'TTS-{}'.format(_tts_num)
if CONFIG['GLOBAL'].get('{}_ENABLED'.format(_prefix), False):
_tts_mode = CONFIG['GLOBAL']['{}_MODE'.format(_prefix)]
if _tts_mode == 'hourly':
_tts_check_interval = 30
else:
_tts_check_interval = CONFIG['GLOBAL']['{}_INTERVAL'.format(_prefix)]
_tts_task = task.LoopingCall(scheduledTTSAnnouncement, _tts_num)
_tts_def = _tts_task.start(_tts_check_interval, now=False)
_tts_def.addErrback(loopingErrHandle)
_tts_tasks[_tts_num] = _tts_task
logger.info('(%s) Scheduled TTS announcements enabled - mode: %s, file: %s, TG: %s, TS: auto, lang: %s',
_label,
_tts_mode,
CONFIG['GLOBAL']['{}_FILE'.format(_prefix)],
CONFIG['GLOBAL']['{}_TG'.format(_prefix)],
CONFIG['GLOBAL']['{}_LANGUAGE'.format(_prefix)])
if _tts_mode == 'interval':
logger.info('(%s) Interval: every %s seconds', _label, _tts_check_interval)
_voice_cfg_config_file = cli_args.CONFIG_FILE
_voice_cfg_dir = os.path.dirname(os.path.abspath(cli_args.CONFIG_FILE))
_voice_cfg_file = os.path.join(_voice_cfg_dir, 'voice.cfg')
if os.path.isfile(_voice_cfg_file):
try:
_voice_cfg_mtime = os.path.getmtime(_voice_cfg_file)
except OSError:
_voice_cfg_mtime = 0
voice_reload_task = task.LoopingCall(_checkVoiceConfigReload)
voice_reload = voice_reload_task.start(15)
voice_reload.addErrback(loopingErrHandle)
logger.info('(VOICE-RELOAD) Vigilancia de voice.cfg activada (cada 15 segundos)')
#Security downloads from central server #Security downloads from central server
init_security_downloads(CONFIG) init_security_downloads(CONFIG)

@ -31,6 +31,7 @@ change.
import configparser import configparser
import sys import sys
import os
import const import const
import socket import socket
@ -44,8 +45,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' __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' __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' __license__ = 'GNU GPLv3'
__maintainer__ = 'Esteban Mackay, HP3ICC' __maintainer__ = 'Esteban Mackay, HP3ICC and Joaquin Madrid, EA5GVK'
__email__ = 'setcom40@gmail.com' __email__ = 'setcom40@gmail.com, ea5gvk@gmail.com'
# Processing of ALS goes here. It's separated from the acl_build function because this # 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 # code is hblink config-file format specific, and acl_build is abstracted
@ -156,9 +157,7 @@ def build_config(_config_file):
'DATA_GATEWAY': config.getboolean(section, 'DATA_GATEWAY', fallback=False), 'DATA_GATEWAY': config.getboolean(section, 'DATA_GATEWAY', fallback=False),
'VALIDATE_SERVER_IDS': config.getboolean(section, 'VALIDATE_SERVER_IDS', fallback=True), 'VALIDATE_SERVER_IDS': config.getboolean(section, 'VALIDATE_SERVER_IDS', fallback=True),
'DEBUG_BRIDGES' : config.getboolean(section, 'DEBUG_BRIDGES', fallback=False), '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']: if not CONFIG['GLOBAL']['ANNOUNCEMENT_LANGUAGES']:
CONFIG['GLOBAL']['ANNOUNCEMENT_LANGUAGES'] = languages CONFIG['GLOBAL']['ANNOUNCEMENT_LANGUAGES'] = languages
@ -396,9 +395,138 @@ def build_config(_config_file):
sys.exit('Error processing configuration file -- {}'.format(err)) sys.exit('Error processing configuration file -- {}'.format(err))
process_acls(CONFIG) 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)
CONFIG['GLOBAL'].update({
'ANNOUNCEMENT_ENABLED': voice_config.getboolean(_voice_section, 'ANNOUNCEMENT_ENABLED', fallback=False) if _has_voice else False,
'ANNOUNCEMENT_FILE': voice_config.get(_voice_section, 'ANNOUNCEMENT_FILE', fallback='locucion') if _has_voice else 'locucion',
'ANNOUNCEMENT_TG': voice_config.getint(_voice_section, 'ANNOUNCEMENT_TG', fallback=9) if _has_voice else 9,
'ANNOUNCEMENT_MODE': voice_config.get(_voice_section, 'ANNOUNCEMENT_MODE', fallback='hourly') if _has_voice else 'hourly',
'ANNOUNCEMENT_INTERVAL': voice_config.getint(_voice_section, 'ANNOUNCEMENT_INTERVAL', fallback=3600) if _has_voice else 3600,
'ANNOUNCEMENT_LANGUAGE': voice_config.get(_voice_section, 'ANNOUNCEMENT_LANGUAGE', fallback='es_ES') if _has_voice else 'es_ES',
'ANNOUNCEMENT2_ENABLED': voice_config.getboolean(_voice_section, 'ANNOUNCEMENT2_ENABLED', fallback=False) if _has_voice else False,
'ANNOUNCEMENT2_FILE': voice_config.get(_voice_section, 'ANNOUNCEMENT2_FILE', fallback='locucion') if _has_voice else 'locucion',
'ANNOUNCEMENT2_TG': voice_config.getint(_voice_section, 'ANNOUNCEMENT2_TG', fallback=9) if _has_voice else 9,
'ANNOUNCEMENT2_MODE': voice_config.get(_voice_section, 'ANNOUNCEMENT2_MODE', fallback='hourly') if _has_voice else 'hourly',
'ANNOUNCEMENT2_INTERVAL': voice_config.getint(_voice_section, 'ANNOUNCEMENT2_INTERVAL', fallback=3600) if _has_voice else 3600,
'ANNOUNCEMENT2_LANGUAGE': voice_config.get(_voice_section, 'ANNOUNCEMENT2_LANGUAGE', fallback='es_ES') if _has_voice else 'es_ES',
'ANNOUNCEMENT3_ENABLED': voice_config.getboolean(_voice_section, 'ANNOUNCEMENT3_ENABLED', fallback=False) if _has_voice else False,
'ANNOUNCEMENT3_FILE': voice_config.get(_voice_section, 'ANNOUNCEMENT3_FILE', fallback='locucion') if _has_voice else 'locucion',
'ANNOUNCEMENT3_TG': voice_config.getint(_voice_section, 'ANNOUNCEMENT3_TG', fallback=9) if _has_voice else 9,
'ANNOUNCEMENT3_MODE': voice_config.get(_voice_section, 'ANNOUNCEMENT3_MODE', fallback='hourly') if _has_voice else 'hourly',
'ANNOUNCEMENT3_INTERVAL': voice_config.getint(_voice_section, 'ANNOUNCEMENT3_INTERVAL', fallback=3600) if _has_voice else 3600,
'ANNOUNCEMENT3_LANGUAGE': voice_config.get(_voice_section, 'ANNOUNCEMENT3_LANGUAGE', fallback='es_ES') if _has_voice else 'es_ES',
'ANNOUNCEMENT4_ENABLED': voice_config.getboolean(_voice_section, 'ANNOUNCEMENT4_ENABLED', fallback=False) if _has_voice else False,
'ANNOUNCEMENT4_FILE': voice_config.get(_voice_section, 'ANNOUNCEMENT4_FILE', fallback='locucion') if _has_voice else 'locucion',
'ANNOUNCEMENT4_TG': voice_config.getint(_voice_section, 'ANNOUNCEMENT4_TG', fallback=9) if _has_voice else 9,
'ANNOUNCEMENT4_MODE': voice_config.get(_voice_section, 'ANNOUNCEMENT4_MODE', fallback='hourly') if _has_voice else 'hourly',
'ANNOUNCEMENT4_INTERVAL': voice_config.getint(_voice_section, 'ANNOUNCEMENT4_INTERVAL', fallback=3600) if _has_voice else 3600,
'ANNOUNCEMENT4_LANGUAGE': voice_config.get(_voice_section, 'ANNOUNCEMENT4_LANGUAGE', fallback='es_ES') if _has_voice else 'es_ES',
'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,
'TTS_ANNOUNCEMENT1_ENABLED': voice_config.getboolean(_voice_section, 'TTS_ANNOUNCEMENT1_ENABLED', fallback=False) if _has_voice else False,
'TTS_ANNOUNCEMENT1_FILE': voice_config.get(_voice_section, 'TTS_ANNOUNCEMENT1_FILE', fallback='texto1') if _has_voice else 'texto1',
'TTS_ANNOUNCEMENT1_TG': voice_config.getint(_voice_section, 'TTS_ANNOUNCEMENT1_TG', fallback=9) if _has_voice else 9,
'TTS_ANNOUNCEMENT1_MODE': voice_config.get(_voice_section, 'TTS_ANNOUNCEMENT1_MODE', fallback='hourly') if _has_voice else 'hourly',
'TTS_ANNOUNCEMENT1_INTERVAL': voice_config.getint(_voice_section, 'TTS_ANNOUNCEMENT1_INTERVAL', fallback=3600) if _has_voice else 3600,
'TTS_ANNOUNCEMENT1_LANGUAGE': voice_config.get(_voice_section, 'TTS_ANNOUNCEMENT1_LANGUAGE', fallback='es_ES') if _has_voice else 'es_ES',
'TTS_ANNOUNCEMENT2_ENABLED': voice_config.getboolean(_voice_section, 'TTS_ANNOUNCEMENT2_ENABLED', fallback=False) if _has_voice else False,
'TTS_ANNOUNCEMENT2_FILE': voice_config.get(_voice_section, 'TTS_ANNOUNCEMENT2_FILE', fallback='texto2') if _has_voice else 'texto2',
'TTS_ANNOUNCEMENT2_TG': voice_config.getint(_voice_section, 'TTS_ANNOUNCEMENT2_TG', fallback=9) if _has_voice else 9,
'TTS_ANNOUNCEMENT2_MODE': voice_config.get(_voice_section, 'TTS_ANNOUNCEMENT2_MODE', fallback='hourly') if _has_voice else 'hourly',
'TTS_ANNOUNCEMENT2_INTERVAL': voice_config.getint(_voice_section, 'TTS_ANNOUNCEMENT2_INTERVAL', fallback=3600) if _has_voice else 3600,
'TTS_ANNOUNCEMENT2_LANGUAGE': voice_config.get(_voice_section, 'TTS_ANNOUNCEMENT2_LANGUAGE', fallback='es_ES') if _has_voice else 'es_ES',
'TTS_ANNOUNCEMENT3_ENABLED': voice_config.getboolean(_voice_section, 'TTS_ANNOUNCEMENT3_ENABLED', fallback=False) if _has_voice else False,
'TTS_ANNOUNCEMENT3_FILE': voice_config.get(_voice_section, 'TTS_ANNOUNCEMENT3_FILE', fallback='texto3') if _has_voice else 'texto3',
'TTS_ANNOUNCEMENT3_TG': voice_config.getint(_voice_section, 'TTS_ANNOUNCEMENT3_TG', fallback=9) if _has_voice else 9,
'TTS_ANNOUNCEMENT3_MODE': voice_config.get(_voice_section, 'TTS_ANNOUNCEMENT3_MODE', fallback='hourly') if _has_voice else 'hourly',
'TTS_ANNOUNCEMENT3_INTERVAL': voice_config.getint(_voice_section, 'TTS_ANNOUNCEMENT3_INTERVAL', fallback=3600) if _has_voice else 3600,
'TTS_ANNOUNCEMENT3_LANGUAGE': voice_config.get(_voice_section, 'TTS_ANNOUNCEMENT3_LANGUAGE', fallback='es_ES') if _has_voice else 'es_ES',
'TTS_ANNOUNCEMENT4_ENABLED': voice_config.getboolean(_voice_section, 'TTS_ANNOUNCEMENT4_ENABLED', fallback=False) if _has_voice else False,
'TTS_ANNOUNCEMENT4_FILE': voice_config.get(_voice_section, 'TTS_ANNOUNCEMENT4_FILE', fallback='texto4') if _has_voice else 'texto4',
'TTS_ANNOUNCEMENT4_TG': voice_config.getint(_voice_section, 'TTS_ANNOUNCEMENT4_TG', fallback=9) if _has_voice else 9,
'TTS_ANNOUNCEMENT4_MODE': voice_config.get(_voice_section, 'TTS_ANNOUNCEMENT4_MODE', fallback='hourly') if _has_voice else 'hourly',
'TTS_ANNOUNCEMENT4_INTERVAL': voice_config.getint(_voice_section, 'TTS_ANNOUNCEMENT4_INTERVAL', fallback=3600) if _has_voice else 3600,
'TTS_ANNOUNCEMENT4_LANGUAGE': voice_config.get(_voice_section, 'TTS_ANNOUNCEMENT4_LANGUAGE', fallback='es_ES') if _has_voice else 'es_ES',
})
return CONFIG 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 = {}
for _ann_num in range(1, 5):
_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 range(1, 5):
_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')
CONFIG['GLOBAL'].update(_voice_keys)
return True
# Used to run this file direclty and print the config, # Used to run this file direclty and print the config,
# which might be useful for debugging # which might be useful for debugging
if __name__ == '__main__': if __name__ == '__main__':

@ -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

@ -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. **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 ### Individual Password Authentication
**Problem**: Need individual password authentication per Radio ID (indicativo) for enhanced security **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 ### Language/Voice Assets
- Pre-recorded AMBE voice files in Audio/ directory - 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) - 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,4 @@ mysql-connector-python
cryptography cryptography
markdown markdown
pdfkit pdfkit
gTTS>=2.3.1

@ -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.