diff --git a/config.template b/config.template index df7660e..cb6e2ff 100644 --- a/config.template +++ b/config.template @@ -353,6 +353,10 @@ voxTrapList = chirpy # allow use of 'weather' and 'joke' commands via VOX voxEnableCmd = True +# Meshages Text-to-Speech (TTS) for incoming messages and DM +meshagesTTS = False +ttsChannels = 2 + # WSJT-X UDP monitoring - listens for decode messages from WSJT-X, FT8/FT4/WSPR etc. wsjtxDetectionEnabled = False # UDP address and port where WSJT-X broadcasts (default: 127.0.0.1:2237) diff --git a/mesh_bot.py b/mesh_bot.py index 52fda9d..d6ac6bc 100755 --- a/mesh_bot.py +++ b/mesh_bot.py @@ -1539,6 +1539,9 @@ def handle_boot(mesh=True): if my_settings.solar_conditions_enabled: logger.debug("System: Celestial Telemetry Enabled") + + if my_settings.meshagesTTS: + logger.debug("System: Meshages TTS Text-to-Speech Enabled") if my_settings.location_enabled: if my_settings.use_meteo_wxApi: @@ -1565,7 +1568,7 @@ def handle_boot(mesh=True): logger.debug(f"System: RSS Feed Reader Enabled for feeds: {rssFeedNames}") if my_settings.radio_detection_enabled: - logger.debug(f"System: Radio Detection Enabled using rigctld at {my_settings.rigControlServerAddress} broadcasting to channels: {my_settings.sigWatchBroadcastCh} for {get_freq_common_name(get_hamlib('f'))}") + logger.debug(f"System: Radio Detection Enabled using rigctld at {my_settings.rigControlServerAddress} broadcasting to channels: {my_settings.sigWatchBroadcastCh}") if my_settings.file_monitor_enabled: logger.warning(f"System: File Monitor Enabled for {my_settings.file_monitor_file_path}, broadcasting to channels: {my_settings.file_monitor_broadcastCh}") @@ -1886,7 +1889,13 @@ def onReceive(packet, interface): else: # respond with help message on DM send_message(help_message, channel_number, message_from_id, rxNode) - + + # add message to tts queue + if meshagesTTS: + # add to the tts_read_queue + readMe = f"DM from {get_name_from_number(message_from_id, 'short', rxNode)}: {message_string}" + tts_read_queue.append(readMe) + # log the message to the message log if log_messages_to_file: msgLogger.info(f"Device:{rxNode} Channel:{channel_number} | {get_name_from_number(message_from_id, 'long', rxNode)} | DM | " + message_string.replace('\n', '-nl-')) @@ -1990,6 +1999,12 @@ def onReceive(packet, interface): if slotMachine: msg = f"🎉 {get_name_from_number(message_from_id, 'long', rxNode)} played the Slot Machine and got: {slotMachine} 🥳" send_message(msg, channel_number, 0, rxNode) + + # add message to tts queue + if my_settings.meshagesTTS and channel_number == my_settings.ttsChannels: + # add to the tts_read_queue + readMe = f"DM from {get_name_from_number(message_from_id, 'short', rxNode)}: {message_string}" + tts_read_queue.append(readMe) else: # Evaluate non TEXT_MESSAGE_APP packets consumeMetadata(packet, rxNode, channel_number) @@ -2041,7 +2056,11 @@ async def main(): tasks.append(asyncio.create_task(handleSignalWatcher(), name="hamlib")) if my_settings.voxDetectionEnabled: + from modules.radio import voxMonitor tasks.append(asyncio.create_task(voxMonitor(), name="vox_detection")) + + if my_settings.meshagesTTS: + tasks.append(asyncio.create_task(handleTTS(), name="tts_handler")) if my_settings.wsjtx_detection_enabled: tasks.append(asyncio.create_task(handleWsjtxWatcher(), name="wsjtx_monitor")) diff --git a/modules/radio.md b/modules/radio.md new file mode 100644 index 0000000..e587fa0 --- /dev/null +++ b/modules/radio.md @@ -0,0 +1,55 @@ +# Radio Module: Meshages TTS (Text-to-Speech) Setup + +The radio module supports audible mesh messages using the [KittenTTS](https://github.com/KittenML/KittenTTS) engine. This allows the bot to generate and play speech from text, making mesh alerts and messages audible on your device. + +## Features + +- Converts mesh messages to speech using KittenTTS. + +## Installation + +1. **Install Python dependencies:** + + - `kittentts` is the TTS engine. + +`pip install https://github.com/KittenML/KittenTTS/releases/download/0.1/kittentts-0.1.0-py3-none-any.whl` + +2. **Install PortAudio (required for sounddevice):** + + - **macOS:** + ```sh + brew install portaudio + ``` + - **Linux (Debian/Ubuntu):** + ```sh + sudo apt-get install portaudio19-dev + ``` + - **Windows:** + No extra step needed; `sounddevice` will use the default audio driver. + +## Configuration + +- Enable TTS in your `config.ini`: + ```ini + [radioMon] + meshagesTTS = True + ``` + +## Usage + +When enabled, the bot will generate and play speech for mesh messages using the selected voice. +No additional user action is required. + +## Troubleshooting + +- If you see errors about missing `sounddevice` or `portaudio`, ensure you have installed the dependencies above. +- On macOS, you may need to allow microphone/audio access for your terminal. +- If you have audio issues, check your system’s default output device. + +## References + +- [KittenTTS GitHub](https://github.com/KittenML/KittenTTS) +- [KittenTTS Model on HuggingFace](https://huggingface.co/KittenML/kitten-tts-nano-0.2) +- [sounddevice documentation](https://python-sounddevice.readthedocs.io/) + +--- \ No newline at end of file diff --git a/modules/radio.py b/modules/radio.py index 6fb7d7c..ed9e284 100644 --- a/modules/radio.py +++ b/modules/radio.py @@ -34,7 +34,8 @@ from modules.settings import ( voxTrapList, voxOnTrapList, voxEnableCmd, - ERROR_FETCHING_DATA + ERROR_FETCHING_DATA, + meshagesTTS, ) # module global variables @@ -140,9 +141,9 @@ try: watched_callsigns = list({cs.upper() for cs in callsigns}) except ImportError: - logger.debug("RadioMon: WSJT-X/JS8Call settings not configured") + logger.debug("System: RadioMon: WSJT-X/JS8Call settings not configured") except Exception as e: - logger.warning(f"RadioMon: Error loading WSJT-X/JS8Call settings: {e}") + logger.warning(f"System: RadioMon: Error loading WSJT-X/JS8Call settings: {e}") if radio_detection_enabled: @@ -176,13 +177,43 @@ if voxDetectionEnabled: voxModel = Model(lang=voxLanguage) # use built in model for specified language except Exception as e: - print(f"RadioMon: Error importing VOX dependencies: {e}") + print(f"System: RadioMon: Error importing VOX dependencies: {e}") print(f"To use VOX detection please install the vosk and sounddevice python modules") print(f"pip install vosk sounddevice") print(f"sounddevice needs pulseaudio, apt-get install portaudio19-dev") voxDetectionEnabled = False - logger.error(f"RadioMon: VOX detection disabled due to import error") + logger.error(f"System: RadioMon: VOX detection disabled due to import error") +if meshagesTTS: + try: + # TTS for meshages imports + logger.debug("System: RadioMon: Initializing TTS model for audible meshages") + import sounddevice as sd + from kittentts import KittenTTS + ttsModel = KittenTTS("KittenML/kitten-tts-nano-0.2") + available_voices = [ + 'expr-voice-2-m', 'expr-voice-2-f', 'expr-voice-3-m', 'expr-voice-3-f', + 'expr-voice-4-m', 'expr-voice-4-f', 'expr-voice-5-m', 'expr-voice-5-f' + ] + except Exception as e: + logger.error(f"To use Meshages TTS please review the radio.md documentation for setup instructions.") + meshagesTTS = False + +async def generate_and_play_tts(text, voice, samplerate=24000): + """Async: Generate speech and play audio.""" + text = text.strip() + if not text: + return + try: + logger.debug(f"System: RadioMon: Generating TTS for text: {text} with voice: {voice}") + audio = await asyncio.to_thread(ttsModel.generate, text, voice=voice) + if audio is None or len(audio) == 0: + return + await asyncio.to_thread(sd.play, audio, samplerate) + await asyncio.to_thread(sd.wait) + del audio + except Exception as e: + logger.warning(f"System: RadioMon: Error in generate_and_play_tts: {e}") def get_freq_common_name(freq): freq = int(freq) @@ -196,14 +227,14 @@ def get_freq_common_name(freq): def get_hamlib(msg="f"): # get data from rigctld server if "socket" not in globals(): - logger.warning("RadioMon: 'socket' module not imported. Hamlib disabled.") + logger.warning("System: RadioMon: 'socket' module not imported. Hamlib disabled.") return ERROR_FETCHING_DATA try: rigControlSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) rigControlSocket.settimeout(2) rigControlSocket.connect((rigControlServerAddress.split(":")[0],int(rigControlServerAddress.split(":")[1]))) except Exception as e: - logger.error(f"RadioMon: Error connecting to rigctld: {e}") + logger.error(f"System: RadioMon: Error connecting to rigctld: {e}") return ERROR_FETCHING_DATA try: @@ -217,7 +248,7 @@ def get_hamlib(msg="f"): data = data.replace(b'\n',b'') return data.decode("utf-8").rstrip() except Exception as e: - logger.error(f"RadioMon: Error fetching data from rigctld: {e}") + logger.error(f"System: RadioMon: Error fetching data from rigctld: {e}") return ERROR_FETCHING_DATA def get_sig_strength(): @@ -227,7 +258,7 @@ def get_sig_strength(): def checkVoxTrapWords(text): try: if not voxOnTrapList: - logger.debug(f"RadioMon: VOX detected: {text}") + logger.debug(f"System: RadioMon: VOX detected: {text}") return text if text: traps = [voxTrapList] if isinstance(voxTrapList, str) else voxTrapList @@ -237,27 +268,27 @@ def checkVoxTrapWords(text): trap_lower = trap_clean.lower() idx = text_lower.find(trap_lower) if debugVoxTmsg: - logger.debug(f"RadioMon: VOX checking for trap word '{trap_lower}' in: '{text}' (index: {idx})") + logger.debug(f"System: RadioMon: VOX checking for trap word '{trap_lower}' in: '{text}' (index: {idx})") if idx != -1: new_text = text[idx + len(trap_clean):].strip() if debugVoxTmsg: - logger.debug(f"RadioMon: VOX detected trap word '{trap_lower}' in: '{text}' (remaining: '{new_text}')") + logger.debug(f"System: RadioMon: VOX detected trap word '{trap_lower}' in: '{text}' (remaining: '{new_text}')") new_words = new_text.split() if voxEnableCmd: for word in new_words: if word in botMethods: - logger.info(f"RadioMon: VOX action '{word}' with '{new_text}'") + logger.info(f"System: RadioMon: VOX action '{word}' with '{new_text}'") if word == "joke": return botMethods[word](vox=True) else: return botMethods[word](None, None, None, vox=True) - logger.debug(f"RadioMon: VOX returning text after trap word '{trap_lower}': '{new_text}'") + logger.debug(f"System: RadioMon: VOX returning text after trap word '{trap_lower}': '{new_text}'") return new_text if debugVoxTmsg: - logger.debug(f"RadioMon: VOX no trap word found in: '{text}'") + logger.debug(f"System: RadioMon: VOX no trap word found in: '{text}'") return None except Exception as e: - logger.debug(f"RadioMon: Error in checkVoxTrapWords: {e}") + logger.debug(f"System: RadioMon: Error in checkVoxTrapWords: {e}") return None async def signalWatcher(): @@ -267,7 +298,7 @@ async def signalWatcher(): signalStrength = int(get_sig_strength()) if signalStrength >= previousStrength and signalStrength > signalDetectionThreshold: message = f"Detected {get_freq_common_name(get_hamlib('f'))} active. S-Meter:{signalStrength}dBm" - logger.debug(f"RadioMon: {message}. Waiting for {signalHoldTime} seconds") + logger.debug(f"System: RadioMon: {message}. Waiting for {signalHoldTime} seconds") previousStrength = signalStrength signalCycle = 0 await asyncio.sleep(signalHoldTime) @@ -287,7 +318,7 @@ async def signalWatcher(): async def make_vox_callback(loop, q): def vox_callback(indata, frames, time, status): if status: - logger.warning(f"RadioMon: VOX input status: {status}") + logger.warning(f"System: RadioMon: VOX input status: {status}") try: loop.call_soon_threadsafe(q.put_nowait, bytes(indata)) except asyncio.QueueFull: @@ -300,7 +331,7 @@ async def make_vox_callback(loop, q): loop.call_soon_threadsafe(q.put_nowait, bytes(indata)) except asyncio.QueueFull: # If still full, just drop this frame - logger.debug("RadioMon: VOX queue full, dropping audio frame") + logger.debug("System: RadioMon: VOX queue full, dropping audio frame") except RuntimeError: # Loop may be closed pass @@ -312,7 +343,7 @@ async def voxMonitor(): model = voxModel device_info = sd.query_devices(voxInputDevice, 'input') samplerate = 16000 - logger.debug(f"RadioMon: VOX monitor started on device {device_info['name']} with samplerate {samplerate} using trap words: {voxTrapList if voxOnTrapList else 'none'}") + logger.debug(f"System: RadioMon: VOX monitor started on device {device_info['name']} with samplerate {samplerate} using trap words: {voxTrapList if voxOnTrapList else 'none'}") rec = KaldiRecognizer(model, samplerate) loop = asyncio.get_running_loop() callback = await make_vox_callback(loop, q) @@ -339,7 +370,7 @@ async def voxMonitor(): await asyncio.sleep(0.1) except Exception as e: - logger.error(f"RadioMon: Error in VOX monitor: {e}") + logger.warning(f"System: RadioMon: Error in VOX monitor: {e}") def decode_wsjtx_packet(data): """Decode WSJT-X UDP packet according to the protocol specification""" @@ -441,7 +472,7 @@ def decode_wsjtx_packet(data): return None except Exception as e: - logger.debug(f"RadioMon: Error decoding WSJT-X packet: {e}") + logger.debug(f"System: RadioMon: Error decoding WSJT-X packet: {e}") return None def check_callsign_match(message, callsigns): @@ -483,7 +514,7 @@ def check_callsign_match(message, callsigns): async def wsjtxMonitor(): """Monitor WSJT-X UDP broadcasts for decode messages""" if not wsjtx_enabled: - logger.warning("RadioMon: WSJT-X monitoring called but not enabled") + logger.warning("System: RadioMon: WSJT-X monitoring called but not enabled") return try: @@ -492,9 +523,9 @@ async def wsjtxMonitor(): sock.bind((wsjtx_udp_address, wsjtx_udp_port)) sock.setblocking(False) - logger.info(f"RadioMon: WSJT-X UDP listener started on {wsjtx_udp_address}:{wsjtx_udp_port}") + logger.info(f"System: RadioMon: WSJT-X UDP listener started on {wsjtx_udp_address}:{wsjtx_udp_port}") if watched_callsigns: - logger.info(f"RadioMon: Watching for callsigns: {', '.join(watched_callsigns)}") + logger.info(f"System: RadioMon: Watching for callsigns: {', '.join(watched_callsigns)}") while True: try: @@ -509,29 +540,29 @@ async def wsjtxMonitor(): # Check if message contains watched callsigns if check_callsign_match(message, watched_callsigns): msg_text = f"WSJT-X {mode}: {message} (SNR: {snr:+d}dB)" - logger.info(f"RadioMon: {msg_text}") + logger.info(f"System: RadioMon: {msg_text}") wsjtxMsgQueue.append(msg_text) except BlockingIOError: # No data available await asyncio.sleep(0.1) except Exception as e: - logger.debug(f"RadioMon: Error in WSJT-X monitor loop: {e}") + logger.debug(f"System: RadioMon: Error in WSJT-X monitor loop: {e}") await asyncio.sleep(1) except Exception as e: - logger.error(f"RadioMon: Error starting WSJT-X monitor: {e}") + logger.warning(f"System: RadioMon: Error starting WSJT-X monitor: {e}") async def js8callMonitor(): """Monitor JS8Call TCP API for messages""" if not js8call_enabled: - logger.warning("RadioMon: JS8Call monitoring called but not enabled") + logger.warning("System: RadioMon: JS8Call monitoring called but not enabled") return try: - logger.info(f"RadioMon: JS8Call TCP listener connecting to {js8call_tcp_address}:{js8call_tcp_port}") + logger.info(f"System: RadioMon: JS8Call TCP listener connecting to {js8call_tcp_address}:{js8call_tcp_port}") if watched_callsigns: - logger.info(f"RadioMon: Watching for callsigns: {', '.join(watched_callsigns)}") + logger.info(f"System: RadioMon: Watching for callsigns: {', '.join(watched_callsigns)}") while True: try: @@ -541,14 +572,14 @@ async def js8callMonitor(): sock.connect((js8call_tcp_address, js8call_tcp_port)) sock.setblocking(False) - logger.info("RadioMon: Connected to JS8Call API") + logger.info("System: RadioMon: Connected to JS8Call API") buffer = "" while True: try: data = sock.recv(4096) if not data: - logger.warning("RadioMon: JS8Call connection closed") + logger.warning("System: RadioMon: JS8Call connection closed") break buffer += data.decode('utf-8', errors='ignore') @@ -572,34 +603,34 @@ async def js8callMonitor(): if text and check_callsign_match(text, watched_callsigns): msg_text = f"JS8Call from {from_call}: {text} (SNR: {snr:+d}dB)" - logger.info(f"RadioMon: {msg_text}") + logger.info(f"System: RadioMon: {msg_text}") js8callMsgQueue.append(msg_text) except json.JSONDecodeError: - logger.debug(f"RadioMon: Invalid JSON from JS8Call: {line[:100]}") + logger.debug(f"System: RadioMon: Invalid JSON from JS8Call: {line[:100]}") except Exception as e: - logger.debug(f"RadioMon: Error processing JS8Call message: {e}") + logger.debug(f"System: RadioMon: Error processing JS8Call message: {e}") except BlockingIOError: await asyncio.sleep(0.1) except socket.timeout: await asyncio.sleep(0.1) except Exception as e: - logger.debug(f"RadioMon: Error in JS8Call receive loop: {e}") + logger.debug(f"System: RadioMon: Error in JS8Call receive loop: {e}") break sock.close() - logger.warning("RadioMon: JS8Call connection lost, reconnecting in 5s...") + logger.warning("System: RadioMon: JS8Call connection lost, reconnecting in 5s...") await asyncio.sleep(5) except socket.timeout: - logger.warning("RadioMon: JS8Call connection timeout, retrying in 5s...") + logger.warning("System: RadioMon: JS8Call connection timeout, retrying in 5s...") await asyncio.sleep(5) except Exception as e: - logger.warning(f"RadioMon: Error connecting to JS8Call: {e}") + logger.warning(f"System: RadioMon: Error connecting to JS8Call: {e}") await asyncio.sleep(10) except Exception as e: - logger.error(f"RadioMon: Error starting JS8Call monitor: {e}") + logger.warning(f"System: RadioMon: Error starting JS8Call monitor: {e}") # end of file diff --git a/modules/settings.py b/modules/settings.py index 9251cec..9c1ebcb 100644 --- a/modules/settings.py +++ b/modules/settings.py @@ -32,6 +32,7 @@ cmdHistory = [] # list to hold the command history for lheard and history comman msg_history = [] # list to hold the message history for the messages command max_bytes = 200 # Meshtastic has ~237 byte limit, use conservative 200 bytes for message content voxMsgQueue = [] # queue for VOX detected messages +tts_read_queue = [] # queue for TTS messages wsjtxMsgQueue = [] # queue for WSJT-X detected messages js8callMsgQueue = [] # queue for JS8Call detected messages # Game trackers @@ -434,6 +435,9 @@ try: voxOnTrapList = config['radioMon'].getboolean('voxOnTrapList', False) # default False voxTrapList = config['radioMon'].get('voxTrapList', 'chirpy').split(',') # default chirpy voxEnableCmd = config['radioMon'].getboolean('voxEnableCmd', True) # default True + meshagesTTS = config['radioMon'].getboolean('meshagesTTS', False) # default False + ttsChannels = config['radioMon'].get('ttsChannels', '2').split(',') # default Channel 2 + ttsnoWelcome = config['radioMon'].getboolean('ttsnoWelcome', False) # default False # WSJT-X and JS8Call monitoring wsjtx_detection_enabled = config['radioMon'].getboolean('wsjtxDetectionEnabled', False) # default WSJT-X detection disabled diff --git a/modules/system.py b/modules/system.py index ea7b8f0..3c5c56a 100644 --- a/modules/system.py +++ b/modules/system.py @@ -288,13 +288,6 @@ if inventory_enabled: trap_list = trap_list + trap_list_inventory # items item, itemlist, itemsell, etc. help_message = help_message + ", item, cart" -# Radio Monitor Configuration -if radio_detection_enabled: - from modules.radio import * # from the spudgunman/meshing-around repo - -if voxDetectionEnabled: - from modules.radio import * # from the spudgunman/meshing-around repo - # File Monitor Configuration if file_monitor_enabled or read_news_enabled or bee_enabled or enable_runShellCmd or cmdShellSentryAlerts: from modules.filemon import * # from the spudgunman/meshing-around repo @@ -1916,7 +1909,8 @@ def get_sysinfo(nodeID=0, deviceID=1): return sysinfo async def handleSignalWatcher(): - global lastHamLibAlert + from modules.radio import signalWatcher + from modules.settings import sigWatchBroadcastCh, sigWatchBroadcastInterface, lastHamLibAlert # monitor rigctld for signal strength and frequency while True: msg = await signalWatcher() @@ -2142,17 +2136,40 @@ async def handleSentinel(deviceID): handleSentinel_loop = 0 # Reset if nothing detected async def process_vox_queue(): - # process the voxMsgQueue - global voxMsgQueue - items_to_process = voxMsgQueue[:] - voxMsgQueue.clear() - if len(items_to_process) > 0: - logger.debug(f"System: Processing {len(items_to_process)} items in voxMsgQueue") - for item in items_to_process: - message = item - for channel in sigWatchBroadcastCh: - if antiSpam and int(channel) != publicChannel: - send_message(message, int(channel), 0, sigWatchBroadcastInterface) + # process the voxMsgQueue + from modules.settings import sigWatchBroadcastCh, sigWatchBroadcastInterface, voxMsgQueue + items_to_process = voxMsgQueue[:] + voxMsgQueue.clear() + if len(items_to_process) > 0: + logger.debug(f"System: Processing {len(items_to_process)} items in voxMsgQueue") + for item in items_to_process: + message = item + for channel in sigWatchBroadcastCh: + if antiSpam and int(channel) != publicChannel: + send_message(message, int(channel), 0, sigWatchBroadcastInterface) + +async def handleTTS(): + from modules.radio import generate_and_play_tts, available_voices + from modules.settings import ttsnoWelcome, tts_read_queue + logger.debug("System: Handle TTS started") + if not ttsnoWelcome: + logger.debug("System: Playing TTS welcome message to disable set 'ttsnoWelcome = True' in settings.ini") + await generate_and_play_tts("Hey its Cheerpy! Thanks for using Meshing-Around on Meshtasstic!", available_voices[0]) + try: + while True: + if tts_read_queue: + tts_read = tts_read_queue.pop(0) + voice = available_voices[0] + # ensure the tts_read ends with a punctuation mark + if not tts_read.endswith(('.', '!', '?')): + tts_read += '.' + try: + await generate_and_play_tts(tts_read, voice) + except Exception as e: + logger.error(f"System: TTShandler error: {e}") + await asyncio.sleep(1) + except Exception as e: + logger.critical(f"System: handleTTS crashed: {e}") async def watchdog(): global localTelemetryData, retry_int1, retry_int2, retry_int3, retry_int4, retry_int5, retry_int6, retry_int7, retry_int8, retry_int9