From aa96b48359057e23c540dc3a1370bc842270cbae Mon Sep 17 00:00:00 2001 From: Joaquin Madrid Belando Date: Sat, 14 Mar 2026 21:13:33 +0100 Subject: [PATCH] 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 ce9e9b09e0fad7cc48417a8a181a3c9652384c45 * 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) --- bridge_master.py | 785 ++++++++++++++++++++++++++++++++++++++++++++--- config.py | 140 ++++++++- config/voice.cfg | 124 ++++++++ replit.md | 61 +++- requirements.txt | 1 + tts_engine.py | 420 +++++++++++++++++++++++++ 6 files changed, 1486 insertions(+), 45 deletions(-) create mode 100644 config/voice.cfg create mode 100644 tts_engine.py diff --git a/bridge_master.py b/bridge_master.py index a74cd4b..efa866b 100644 --- a/bridge_master.py +++ b/bridge_master.py @@ -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) @@ -2609,6 +3252,8 @@ class routerHBP(HBSYSTEM): self.STATUS[_slot]['lastSeq'] = _seq #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() @@ -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) @@ -3126,7 +3778,64 @@ if __name__ == '__main__': killserver_task = task.LoopingCall(kill_server) 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) diff --git a/config.py b/config.py index 4545241..6863957 100755 --- a/config.py +++ b/config.py @@ -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 @@ -396,9 +395,138 @@ def build_config(_config_file): sys.exit('Error processing configuration file -- {}'.format(err)) process_acls(CONFIG) - + + _voice_cfg_dir = os.path.dirname(os.path.abspath(_config_file)) + _voice_cfg_file = os.path.join(_voice_cfg_dir, 'voice.cfg') + + voice_config = configparser.ConfigParser() + if os.path.isfile(_voice_cfg_file): + voice_config.read(_voice_cfg_file) + print('(CONFIG) Voice configuration loaded from {}'.format(_voice_cfg_file)) + else: + print('(CONFIG) Voice configuration file not found ({}), using defaults (announcements/recording disabled)'.format(_voice_cfg_file)) + + _voice_section = 'VOICE' + _has_voice = voice_config.has_section(_voice_section) + + 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__': diff --git a/config/voice.cfg b/config/voice.cfg new file mode 100644 index 0000000..54e815c --- /dev/null +++ b/config/voice.cfg @@ -0,0 +1,124 @@ +[VOICE] +; Locuciones programadas +; ANNOUNCEMENT_FILE: Nombre del archivo .ambe (sin extension, se busca en Audio//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//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 diff --git a/replit.md b/replit.md index 3c3c28e..01300a9 100644 --- a/replit.md +++ b/replit.md @@ -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//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//ondemand/.ambe) + +**Behavior**: +- Records any voice transmission on the configured TG/TS +- Extracts raw AMBE bursts (108-bit pairs) from DMRD voice frames +- Maximum recording duration: 2 minutes 45 seconds (2750 frames) +- Automatically saves when transmission ends (voice terminator) or max duration reached +- Saved file is directly compatible with the announcement playback system +- Recording runs in the reactor thread (no blocking) +- Only processes validated frames (after duplicate/rate/loop checks) +- Log label: GRABACION + +**Integration with Announcements**: +- Record with `RECORDING_FILE: mi_locucion` and `RECORDING_LANGUAGE: es_ES` +- Playback with `ANNOUNCEMENT_FILE: mi_locucion` and `ANNOUNCEMENT_LANGUAGE: es_ES` + ### Individual Password Authentication **Problem**: Need individual password authentication per Radio ID (indicativo) for enhanced security @@ -206,4 +265,4 @@ curl -L "http://URL_SECURITY:PORT_SECURITY/descargar?pass=PASS_SECURITY&user_pas ### Language/Voice Assets - Pre-recorded AMBE voice files in Audio/ directory - Multiple language support (en_GB, es_ES, fr_FR, de_DE, dk_DK, it_IT, no_NO, pl_PL, se_SE, pt_PT, cy_GB, el_GR, th_TH, CW) -- Voice file indexing via i8n_voice_map.py \ No newline at end of file +- Voice file indexing via i8n_voice_map.py diff --git a/requirements.txt b/requirements.txt index 42576ad..8a4f2d2 100755 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,4 @@ mysql-connector-python cryptography markdown pdfkit +gTTS>=2.3.1 diff --git a/tts_engine.py b/tts_engine.py new file mode 100644 index 0000000..257fe5a --- /dev/null +++ b/tts_engine.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python +# +############################################################################### +# Copyright (C) 2026 Joaquin Madrid Belando, EA5GVK +# +# 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