diff --git a/tts_engine.py b/tts_engine.py index c36d19e..bb5eb46 100644 --- a/tts_engine.py +++ b/tts_engine.py @@ -24,15 +24,22 @@ 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) - - External AMBE vocoder (configured via TTS_VOCODER_CMD in voice.cfg) - Example vocoders: md380-vocoder, DV3000/ThumbDV serial interface + - 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__) @@ -45,6 +52,25 @@ _LANG_MAP = { '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_RATEP_DMR = bytes([ + 0x61, 0x00, 0x0D, 0x00, 0x0A, + 0x01, 0x30, 0x07, 0x63, 0x40, + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x48 +]) + +DV3K_PRODID_REQ = bytes([0x61, 0x00, 0x01, 0x00, 0x30]) + def _get_tts_lang(announcement_language): if announcement_language in _LANG_MAP: @@ -93,10 +119,8 @@ def _convert_to_wav(mp3_path, wav_path): return False -def _encode_ambe(wav_path, ambe_path, vocoder_cmd): +def _encode_ambe_vocoder(wav_path, ambe_path, vocoder_cmd): if not vocoder_cmd: - logger.error('(TTS) No hay vocoder configurado (TTS_VOCODER_CMD vacio). ' - 'Configura la ruta al vocoder en voice.cfg o coloca un archivo .ambe pre-convertido') return False cmd = vocoder_cmd.replace('{wav}', wav_path).replace('{ambe}', ambe_path) @@ -111,7 +135,7 @@ def _encode_ambe(wav_path, ambe_path, vocoder_cmd): 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: %s', ambe_path) + 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)') @@ -124,7 +148,149 @@ def _encode_ambe(wav_path, ambe_path, vocoder_cmd): return False -def text_to_ambe(txt_path, ambe_path, language, vocoder_cmd): +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): + logger.info('(TTS-AMBESERVER) Conectando a AMBEServer %s:%d', host, port) + + 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_RATEP_DMR, (host, port)) + data, addr = sock.recvfrom(1024) + if data[0] != DV3K_START_BYTE: + logger.error('(TTS-AMBESERVER) Error configurando RATEP DMR') + sock.close() + return False + logger.info('(TTS-AMBESERVER) RATEP DMR (AMBE+2) configurado') + except socket.timeout: + logger.error('(TTS-AMBESERVER) Timeout configurando RATEP') + 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): if not os.path.isfile(txt_path): logger.warning('(TTS) Archivo de texto no encontrado: %s', txt_path) return False @@ -162,15 +328,26 @@ def text_to_ambe(txt_path, ambe_path, language, vocoder_cmd): _cleanup([_mp3_path]) return False - if not vocoder_cmd: - logger.warning('(TTS) No hay vocoder configurado. Archivos intermedios disponibles:') + _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) Convierte manualmente el WAV a AMBE y guardalo como: %s', ambe_path) - return False - - if not _encode_ambe(_wav_path, ambe_path, vocoder_cmd): - _cleanup([_mp3_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]) @@ -197,6 +374,8 @@ def ensure_tts_ambe(config, tts_num): _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) _txt_path = './Audio/{}/ondemand/{}.txt'.format(_lang, _file) _ambe_path = './Audio/{}/ondemand/{}.ambe'.format(_lang, _file) @@ -215,7 +394,7 @@ def ensure_tts_ambe(config, tts_num): 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): + if text_to_ambe(_txt_path, _ambe_path, _lang, _vocoder_cmd, _ambeserver_host, _ambeserver_port): return _ambe_path else: if os.path.isfile(_ambe_path):