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
import sys
import os
from bitarray import bitarray
from time import time,sleep,perf_counter
import importlib.util
@ -52,7 +53,7 @@ from hashlib import blake2b
# Twisted is pretty important, so I keep it separate
from twisted.internet.protocol import Factory, Protocol
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 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 import decode, bptc, const
import config
from config import acl_build
from config import acl_build, reload_voice_config
import log
from const import *
from mk_voice import pkt_gen
@ -74,12 +75,13 @@ from utils import load_json, save_json
#Read voices
from read_ambe import readAMBE
from tts_engine import ensure_tts_ambe
#Remap some words for certain languages
from i8n_voice_map import voiceMap
# Stuff for socket reporting
import pickle
# REMOVE LATER from datetime import datetime
from datetime import datetime
# The module needs logging, but handlers, etc. are controlled by the parent
import logging
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'
__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 - Joaquin Madrid, EA5GVK'
__email__ = 'setcom40@gmail.com - ea5gvk@gmail.com'
#Set header bits
#used for slot rewrite and type rewrite
@ -334,7 +336,7 @@ def make_single_reflector(_tgid,_tmout,_sourcesystem):
def remove_bridge_system(system):
_bridgestemp = {}
_bridgetemp = {}
for _bridge in BRIDGES:
for _bridge in list(BRIDGES):
for _bridgesystem in BRIDGES[_bridge]:
if _bridgesystem['SYSTEM'] != system:
if _bridge not in _bridgestemp:
@ -349,7 +351,7 @@ def remove_bridge_system(system):
def deactivate_all_dynamic_bridges(system_name):
"""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
continue
for _sys_entry in BRIDGES[_bridge]:
@ -368,7 +370,9 @@ def rule_timer_loop():
# Mantener registro de bridges dinámicos activos por sistema
_active_dynamic_bridges = {}
for _bridge in BRIDGES:
_debug_msgs = []
for _bridge in list(BRIDGES):
_bridge_used = False
### 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:
_active_dynamic_bridges[_system['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:
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':
if _system['ACTIVE'] == False:
# 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']))
else:
_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:
# COMPORTAMIENTO ORIGINAL (SINGLE MODE ACTIVADO o bridges estáticos o TGIDs especiales)
if _system['TO_TYPE'] == 'ON':
@ -427,7 +431,7 @@ def rule_timer_loop():
_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)
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':
if _system['ACTIVE'] == False:
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)
elif _system['ACTIVE'] == 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:
if _system['SYSTEM'][0:3] != 'OBP':
_bridge_used = True
elif _system['SYSTEM'][0:3] == 'OBP' and _system['TO_TYPE'] == 'STAT':
_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:
_remove_bridges.append(_bridge)
if _debug_msgs:
logger.debug('\n'.join(_debug_msgs))
for _bridgerem in _remove_bridges:
del BRIDGES[_bridgerem]
logger.debug('(ROUTER) Unused conference bridge %s removed',_bridgerem)
if CONFIG['REPORTS']['REPORT']:
report_server.send_clients(b'bridge updated')
reactor.callFromThread(report_server.send_clients, b'bridge updated')
### END MODIFIED ###
def statTrimmer():
logger.debug('(ROUTER) STAT trimmer loop started')
_remove_bridges = deque()
for _bridge in BRIDGES:
for _bridge in list(BRIDGES):
_bridge_stat = False
_in_use = False
for _system in BRIDGES[_bridge]:
@ -496,8 +503,8 @@ def bridgeDebug():
bridgeroll = 0
dialroll = 0
activeroll = 0
for _bridge in BRIDGES:
for enabled_system in BRIDGES[_bridge]:
for _bridge in list(BRIDGES):
for enabled_system in BRIDGES.get(_bridge, []):
if enabled_system['SYSTEM'] == system:
bridgeroll += 1
if enabled_system['ACTIVE']:
@ -515,8 +522,8 @@ def bridgeDebug():
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)
times = {}
for _bridge in BRIDGES:
for enabled_system in BRIDGES[_bridge]:
for _bridge in list(BRIDGES):
for enabled_system in BRIDGES.get(_bridge, []):
if enabled_system['ACTIVE'] and _bridge and _bridge[0:1] == '#':
times[enabled_system['TIMER']] = _bridge
ordered = sorted(times.keys())
@ -709,13 +716,16 @@ def sendSpeech(self,speech):
_nine = bytes_3(9)
_source_id = bytes_3(5000)
_slot = systems[system].STATUS[2]
_next_time = time()
while True:
try:
pkt = next(speech)
except StopIteration:
break
#Packet every 60ms
sleep(0.058)
_next_time += 0.058
_delay = _next_time - time()
if _delay > 0.001:
sleep(_delay)
reactor.callFromThread(sendVoicePacket,self,pkt,_source_id,_nine,_slot)
logger.debug('(%s) Sendspeech thread ended',self._system)
@ -747,45 +757,66 @@ def disconnectedVoice(system):
sleep(1)
_slot = systems[system].STATUS[2]
_next_time = time()
while True:
try:
pkt = next(speech)
except StopIteration:
break
#Packet every 60ms
sleep(0.058)
_next_time += 0.058
_delay = _next_time - time()
if _delay > 0.001:
sleep(_delay)
_stream_id = pkt[16:20]
_pkt_time = time()
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):
system = self._system
_lang = CONFIG['SYSTEMS'][system]['ANNOUNCEMENT_LANGUAGE']
_nine = bytes_3(9)
_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)
_say = []
try:
_say.append(AMBEobj.readSingleFile(''.join(['/',_lang,'/ondemand/',str(fileNumber),'.ambe'])))
except IOError:
logger.warning('(%s) cannot read file for number %s',system,fileNumber)
_say.append(AMBEobj.readSingleFile(_ambe_file))
except Exception as e:
logger.warning('(%s) Error reading AMBE file %s: %s', system, _full_path, e)
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)
sleep(1)
_slot = systems[system].STATUS[2]
_next_time = time()
_pkt_count = 0
while True:
try:
pkt = next(speech)
except StopIteration:
break
#Packet every 60ms
sleep(0.058)
_next_time += 0.058
_delay = _next_time - time()
if _delay > 0.001:
sleep(_delay)
_stream_id = pkt[16:20]
_pkt_time = time()
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():
logger.debug('(IDENT) starting ident thread')
@ -861,18 +892,629 @@ def ident():
sleep(1)
_slot = systems[system].STATUS[2]
_next_time = time()
while True:
try:
pkt = next(speech)
except StopIteration:
break
#Packet every 60ms
sleep(0.058)
_next_time += 0.058
_delay = _next_time - time()
if _delay > 0.001:
sleep(_delay)
_stream_id = pkt[16:20]
_pkt_time = time()
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():
logger.debug('(BRIDGERESET) Running bridge resetter')
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)
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'))
else:
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)
@ -2610,6 +3253,8 @@ class routerHBP(HBSYSTEM):
#Save this packet
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
_sysIgnore = deque()
_current_bridge_key = str(int_id(_dst_id))
@ -2841,6 +3486,7 @@ if __name__ == '__main__':
if cli_args.LOG_LEVEL:
CONFIG['LOGGER']['LOG_LEVEL'] = cli_args.LOG_LEVEL
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) 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')
@ -3069,7 +3715,9 @@ if __name__ == '__main__':
# logger.info('(API) API not started')
# 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.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
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.addErrback(loopingErrHandle)
#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.addErrback(loopingErrHandle)
@ -3127,6 +3779,63 @@ if __name__ == '__main__':
killserver = killserver_task.start(5)
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
init_security_downloads(CONFIG)

@ -31,6 +31,7 @@ change.
import configparser
import sys
import os
import const
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'
__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 +157,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
@ -397,8 +396,137 @@ def build_config(_config_file):
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
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,
# which might be useful for debugging
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.
### 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

@ -12,3 +12,4 @@ mysql-connector-python
cryptography
markdown
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.