diff --git a/2025gcs/backend/constants.py b/2025gcs/backend/constants.py new file mode 100644 index 00000000..67265333 --- /dev/null +++ b/2025gcs/backend/constants.py @@ -0,0 +1,54 @@ +# # Dictionary to maintain vehicle state +# vehicle_data = { +# "last_time": 0, +# "lat": 0, / +# "lon": 0, / +# "rel_alt": 0, +# "alt": 0, +# "roll": 0, / +# "pitch": 0, / +# "yaw": 0, / +# "dlat": 0, +# "dlon": 0, +# "dalt": 0, +# "heading": 0, +# "groundspeed": 0, +# "throttle": 0, +# "climb": 0, +# "flight_mode": 0, +# "battery_voltage": 0, +# "battery_current": 0, +# "battery_remaining": 0, +# "is_dropped": False +# } + +operation_sigchange = { + 0: { + "battery_voltage": 22.2, #For total, For one cell: 3.7 + "battery_remaining": 10, #In percent + "battery_current": 0, + "alt": 107 #In meters + }, + 1: ["last_time", "lat", "lon", "rel_alt", "roll", "pitch", "yaw", "dlat", "dlon", "dalt", "heading", + "groundspeed", "throttle", "climb", "flight_mode", "is_dropped"], + 2: "Placeholder" +} + +CRITICAL_THRESHOLD = { + "battery_voltage": 22.2, #For total, For one cell: 3.7 + "battery_remaining": 10, #In percent + "battery_current": 0, + "alt": 107 #In meters +} + +INFO_INCREMENT_THRESHOLDS = { + "alt": 5, # Alert every 5 meters altitude change + "groundspeed": 5 # Alert every 5 unit groundspeed change + # "airspeed": 5 # not yet availible but remove groundspeed when it is + } + +CALLOUT_DEBOUNCE_TIME = 5.0 # Debounce time for repeated callouts in seconds (so we don't spam the same callout) + +CRITICAL = 0 +INFO = 1 +DEBUG = 2 \ No newline at end of file diff --git a/2025gcs/backend/helper.py b/2025gcs/backend/helper.py index 6c40a084..76b281d1 100644 --- a/2025gcs/backend/helper.py +++ b/2025gcs/backend/helper.py @@ -1,5 +1,13 @@ import os import json +import threading +import time +import numpy as np +import sounddevice as sd +from piper.voice import PiperVoice +from pathlib import Path +from constants import CRITICAL, INFO, DEBUG, INFO_INCREMENT_THRESHOLDS, CRITICAL_THRESHOLD ,CALLOUT_DEBOUNCE_TIME + ODM_TAGS = os.path.join(os.path.dirname(__file__), 'data', 'ODM', 'odm_geotags.txt') TARGETS_CACHE = os.path.join(os.path.dirname(__file__), 'data', 'TargetInformation.json') @@ -41,4 +49,133 @@ def serialize(class_name : str, conf : float, lat : float, lon : float) -> None: json.dump(data, file, indent=4) print("Detection cached.") except Exception as e: - print(f"Error appending to cache: {e}") \ No newline at end of file + print(f"Error appending to cache: {e}") + +# Piper TTS configuration +PIPER_MODEL_PATH = os.path.join(os.path.dirname(__file__), 'piper_model', 'en_US-ryan-low.onnx') +class CallOuts: + """Manages text-to-speech call outs with priority levels.""" + def __init__(self, voice_model_path: str = PIPER_MODEL_PATH): + self.silenced_calls = [] + self.last_callout_time = {} + self.last_values = {} # Track previous values for increment detection + self.tts_lock = threading.Lock() # Prevent overlapping speech (allow only one at a time) + + # Load Piper voice model + voice_path = Path(voice_model_path) + self.voice = PiperVoice.load(str(voice_path)) + + def speak(self, text: str): + """Stream synthesized audio chunks directly to the speaker.""" + stream = None + try: + for chunk in self.voice.synthesize(text): + if stream is None: + stream = sd.OutputStream( + samplerate = chunk.sample_rate, + channels = chunk.sample_channels, + dtype="int16" + ) + stream.start() + + audio = np.frombuffer(chunk.audio_int16_bytes, dtype=np.int16) + stream.write(audio) + finally: + if stream: + stream.stop() + stream.close() + + def CallOut(self, level: int, msg: str): + """Text to speech using levels for confiming call out""" + # Check if this call level is silenced + if level in self.silenced_calls: + print(f"[SILENCED] {msg}") + return + + # Check debounce time + current_time = time.time() + if msg in self.last_callout_time: + time_since_left = current_time - self.last_callout_time[msg] + if time_since_left < CALLOUT_DEBOUNCE_TIME: + return + + # Update last callout time + self.last_callout_time[msg] = current_time + + # Speak in a seperate thread to avoid blocking + def threaded_speak(): + with self.tts_lock: + self.speak(msg) + + thread = threading.Thread(target=threaded_speak, daemon=True) + thread.start() + + def get_silenced_calls(self): + """Gets all the calls that are silenced""" + return self.silenced_calls + + def silence_calls(self, call_level: int): + """Adds call level to silenced calls""" + if call_level != CRITICAL: + if call_level not in self.silenced_calls: + self.silenced_calls.append(call_level) + + def unsilence_calls (self, call_level: int): + """Removes call level from silenced calls""" + if call_level in self.silenced_calls: + self.silenced_calls.remove(call_level) + + def check_critical_thresholds(self, vehicle_data: dict): + """Check vehicle data against critical thresholds""" + for key, critical_value in CRITICAL_THRESHOLD.items(): + if key in vehicle_data: + current_value = vehicle_data[key] + if current_value <= critical_value: + if key == "battery_voltage": + msg = f"Critical battery: {current_value} volts" + elif key == "battery_remaining": + msg = f"Critical battery: {current_value} percent" + elif key == "alt": + msg = f"Critical altitude: {current_value} meters" + elif key == "battery_current": + msg = f"High current draw: {current_value} amp" + else: + msg = f"Critical {key.replace('_', ' ')}: {current_value}" + self.CallOut(CRITICAL, msg) + + def check_info_increments(self, vehicle_data: dict): + """Check vehicle data for information increments""" + for key, increment in INFO_INCREMENT_THRESHOLDS.items(): + if key in vehicle_data: + current_value = vehicle_data[key] + + if key not in self.last_values: + self.last_values[key] = current_value + continue + + last_value = self.last_values[key] + difference = abs(current_value - last_value) + + if difference >= increment: + if key == "alt": + msg = f"Altitude: {current_value} meters" + elif key == "groundspeed": + msg = f"Ground speed: {current_value} meters per second" + else: + msg = f"{key.replace('_', ' ')}: {current_value}" + self.CallOut(INFO, msg) + # Update to current value as new baseline + self.last_values[key] = current_value + + def check_vehicle_data(self, vehicle_data: dict): + """Check vehicle data and trigger TTS callouts as needed""" + self.check_critical_thresholds(vehicle_data) + self.check_info_increments(vehicle_data) + + def shutdown(self): + """Clean up resources if needed""" + print("Shutting down TTS...") + with self.tts_lock: + pass + # Give sounddevice time to cleanup + time.sleep(0.5) diff --git a/2025gcs/backend/piper_model/en_US-ryan-low.onnx b/2025gcs/backend/piper_model/en_US-ryan-low.onnx new file mode 100644 index 00000000..9a6ff9d7 Binary files /dev/null and b/2025gcs/backend/piper_model/en_US-ryan-low.onnx differ diff --git a/2025gcs/backend/piper_model/en_US-ryan-low.onnx.json b/2025gcs/backend/piper_model/en_US-ryan-low.onnx.json new file mode 100644 index 00000000..ec6ce9d5 --- /dev/null +++ b/2025gcs/backend/piper_model/en_US-ryan-low.onnx.json @@ -0,0 +1,420 @@ +{ + "audio": { + "sample_rate": 16000, + "quality": "low" + }, + "espeak": { + "voice": "en-us" + }, + "inference": { + "noise_scale": 0.667, + "length_scale": 1, + "noise_w": 0.8 + }, + "phoneme_map": {}, + "phoneme_id_map": { + "_": [ + 0 + ], + "^": [ + 1 + ], + "$": [ + 2 + ], + " ": [ + 3 + ], + "!": [ + 4 + ], + "'": [ + 5 + ], + "(": [ + 6 + ], + ")": [ + 7 + ], + ",": [ + 8 + ], + "-": [ + 9 + ], + ".": [ + 10 + ], + ":": [ + 11 + ], + ";": [ + 12 + ], + "?": [ + 13 + ], + "a": [ + 14 + ], + "b": [ + 15 + ], + "c": [ + 16 + ], + "d": [ + 17 + ], + "e": [ + 18 + ], + "f": [ + 19 + ], + "h": [ + 20 + ], + "i": [ + 21 + ], + "j": [ + 22 + ], + "k": [ + 23 + ], + "l": [ + 24 + ], + "m": [ + 25 + ], + "n": [ + 26 + ], + "o": [ + 27 + ], + "p": [ + 28 + ], + "q": [ + 29 + ], + "r": [ + 30 + ], + "s": [ + 31 + ], + "t": [ + 32 + ], + "u": [ + 33 + ], + "v": [ + 34 + ], + "w": [ + 35 + ], + "x": [ + 36 + ], + "y": [ + 37 + ], + "z": [ + 38 + ], + "æ": [ + 39 + ], + "ç": [ + 40 + ], + "ð": [ + 41 + ], + "ø": [ + 42 + ], + "ħ": [ + 43 + ], + "ŋ": [ + 44 + ], + "œ": [ + 45 + ], + "ǀ": [ + 46 + ], + "ǁ": [ + 47 + ], + "ǂ": [ + 48 + ], + "ǃ": [ + 49 + ], + "ɐ": [ + 50 + ], + "ɑ": [ + 51 + ], + "ɒ": [ + 52 + ], + "ɓ": [ + 53 + ], + "ɔ": [ + 54 + ], + "ɕ": [ + 55 + ], + "ɖ": [ + 56 + ], + "ɗ": [ + 57 + ], + "ɘ": [ + 58 + ], + "ə": [ + 59 + ], + "ɚ": [ + 60 + ], + "ɛ": [ + 61 + ], + "ɜ": [ + 62 + ], + "ɞ": [ + 63 + ], + "ɟ": [ + 64 + ], + "ɠ": [ + 65 + ], + "ɡ": [ + 66 + ], + "ɢ": [ + 67 + ], + "ɣ": [ + 68 + ], + "ɤ": [ + 69 + ], + "ɥ": [ + 70 + ], + "ɦ": [ + 71 + ], + "ɧ": [ + 72 + ], + "ɨ": [ + 73 + ], + "ɪ": [ + 74 + ], + "ɫ": [ + 75 + ], + "ɬ": [ + 76 + ], + "ɭ": [ + 77 + ], + "ɮ": [ + 78 + ], + "ɯ": [ + 79 + ], + "ɰ": [ + 80 + ], + "ɱ": [ + 81 + ], + "ɲ": [ + 82 + ], + "ɳ": [ + 83 + ], + "ɴ": [ + 84 + ], + "ɵ": [ + 85 + ], + "ɶ": [ + 86 + ], + "ɸ": [ + 87 + ], + "ɹ": [ + 88 + ], + "ɺ": [ + 89 + ], + "ɻ": [ + 90 + ], + "ɽ": [ + 91 + ], + "ɾ": [ + 92 + ], + "ʀ": [ + 93 + ], + "ʁ": [ + 94 + ], + "ʂ": [ + 95 + ], + "ʃ": [ + 96 + ], + "ʄ": [ + 97 + ], + "ʈ": [ + 98 + ], + "ʉ": [ + 99 + ], + "ʊ": [ + 100 + ], + "ʋ": [ + 101 + ], + "ʌ": [ + 102 + ], + "ʍ": [ + 103 + ], + "ʎ": [ + 104 + ], + "ʏ": [ + 105 + ], + "ʐ": [ + 106 + ], + "ʑ": [ + 107 + ], + "ʒ": [ + 108 + ], + "ʔ": [ + 109 + ], + "ʕ": [ + 110 + ], + "ʘ": [ + 111 + ], + "ʙ": [ + 112 + ], + "ʛ": [ + 113 + ], + "ʜ": [ + 114 + ], + "ʝ": [ + 115 + ], + "ʟ": [ + 116 + ], + "ʡ": [ + 117 + ], + "ʢ": [ + 118 + ], + "ʲ": [ + 119 + ], + "ˈ": [ + 120 + ], + "ˌ": [ + 121 + ], + "ː": [ + 122 + ], + "ˑ": [ + 123 + ], + "˞": [ + 124 + ], + "β": [ + 125 + ], + "θ": [ + 126 + ], + "χ": [ + 127 + ], + "ᵻ": [ + 128 + ], + "ⱱ": [ + 129 + ] + }, + "num_symbols": 130, + "num_speakers": 1, + "speaker_id_map": {}, + "piper_version": "0.2.0", + "language": { + "code": "en_US", + "family": "en", + "region": "US", + "name_native": "English", + "name_english": "English", + "country_english": "United States" + }, + "dataset": "ryan" +} \ No newline at end of file diff --git a/2025gcs/backend/requirements.txt b/2025gcs/backend/requirements.txt index 5ebd7a50..6ebfb4e2 100644 --- a/2025gcs/backend/requirements.txt +++ b/2025gcs/backend/requirements.txt @@ -5,5 +5,7 @@ flask==2.1.1 Flask-Cors==5.0.0 werkzeug==2.1.2 python-dotenv==1.0.1 +piper-tts==1.3.0 +sounddevice==0.5.3 inference-sdk inference-cli \ No newline at end of file diff --git a/2025gcs/backend/server.py b/2025gcs/backend/server.py index e46b1d5f..8f7402da 100644 --- a/2025gcs/backend/server.py +++ b/2025gcs/backend/server.py @@ -5,6 +5,11 @@ from flask_cors import CORS import requests from geo import get_target_coordinates +from helper import CallOuts +from constants import CRITICAL, INFO, DEBUG, operation_sigchange + + +CALLOUT = CallOuts() app = Flask(__name__) CORS(app, resources={r"/*": {"origins": "*"}}) @@ -83,6 +88,10 @@ def get_heartbeat(): current_target = None vehicle_data.update(heartbeat_data) + + # Check for callouts based on vehicle data changes + CALLOUT.check_vehicle_data(vehicle_data) + return jsonify({'success': True, 'vehicle_data': vehicle_data}), 200 except requests.exceptions.RequestException as e: @@ -638,9 +647,78 @@ def set_flight_mode(): status_code = getattr(e.response, "status_code", 500) # Default to 500 if no response print(f"Request Error ({status_code}): {str(e)}") return jsonify({'success': False, 'error': f"Error {status_code}: {str(e)}"}), status_code + + +# ======================== TTS Management ======================== + +@app.post('/silence_callout') +def silence_callout(): + """ + Silences a specific callout level (eg: INFO). + Payload example: {"level": 1} + """ + try: + data = request.json + level = data.get('level') # allows for user to silence either critical or info callouts (if its too noisy) + + if level is None: + return jsonify({'success': False, 'error': 'No level provided'}), 400 + + # Attempt to silence via the helper class + CALLOUT.silence_calls(int(level)) + + return jsonify({ + 'success': True, + 'silenced_calls': CALLOUT.get_silenced_calls() + }), 200 + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.post('/unsilence_callout') +def unsilence_callout(): + """ + Unsilences a specific callout level (eg: INFO). + Payload example: {"level": 1} + """ + try: + data = request.json + level = data.get('level') # allows for user to unsilence either critical or info callouts + + if level is None: + return jsonify({'success': False, 'error': 'No level provided'}), 400 + + # Attempt to unsilence via the helper class + CALLOUT.unsilence_calls(int(level)) + + return jsonify({ + 'success': True, + 'silenced_calls': CALLOUT.get_silenced_calls() + }), 200 + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.get('/get_silenced_calls') +def get_silenced_calls(): + """Returns the list of currently silenced levels.""" + try: + return jsonify({ + 'success': True, + 'silenced_calls': CALLOUT.get_silenced_calls() + }), 200 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +# ======================== TTS Management ======================== if __name__ == '__main__': ''' May need to run this server with sudo (admin) permissions if you encounter blocked networking issues when making API requests to the flight controller. ''' + #initialize the global caller + CALLOUT = CallOuts() + app.run(debug=False, host='0.0.0.0', port=80) diff --git a/2025gcs/backend/test-piper.py b/2025gcs/backend/test-piper.py new file mode 100644 index 00000000..f873565e --- /dev/null +++ b/2025gcs/backend/test-piper.py @@ -0,0 +1,46 @@ +import time +from helper import CallOuts +from constants import CRITICAL, INFO, DEBUG + +# Initialize the CallOuts system +callout = CallOuts() + +def print_test_header(test_name): + print("\n" + "="*50) + print(f"TEST: {test_name}") + print("="*50) + +# TEST: Multiple Critical Values Simultaneously +print_test_header("MULTIPLE CRITICALS - RAPID FIRE") + +# Create a vehicle data snapshot with ALL critical conditions triggered +vehicle_data = { + "battery_voltage": 20.0, # Critical (threshold: 22.2) + "battery_remaining": 8, # Critical (threshold: 10) + "alt": 100, # Critical (threshold: 107) + "battery_current": 0, # Critical (threshold: 0) + # Other normal values + "lat": 51.0447, + "lon": -114.0719, + "groundspeed": 25, + "flight_mode": 4 +} + +print("• Battery voltage: 20.0V (critical: 22.2V)") +print("• Battery remaining: 8% (critical: 10%)") +print("• Altitude: 100m (critical: 107m)") +print("• Battery current: 15A (critical: 0A)") +print("\nExpected: Should hear 4 critical messages in rapid succession") + +# Trigger all criticals at once +callout.check_vehicle_data(vehicle_data) + +print("\nAll critical messages fired simultaneously") +print("Waiting for TTS to finish...") +time.sleep(8) # Wait for all messages to play + +print("\nTEST COMPLETE - Multiple criticals handled!") +callout.shutdown() + +# things to add: +# silencing/unsilencing in the server.py - apis to call them \ No newline at end of file diff --git a/2025gcs/frontend/src/Components/NavBar/NavBar.js b/2025gcs/frontend/src/Components/NavBar/NavBar.js index 28bba507..9df78be5 100644 --- a/2025gcs/frontend/src/Components/NavBar/NavBar.js +++ b/2025gcs/frontend/src/Components/NavBar/NavBar.js @@ -2,6 +2,27 @@ import React from 'react'; import { Link, useLocation } from 'react-router-dom'; const NavBar = () => { + + const [muted, setMuted] = React.useState(false); + const toggleCallouts = async () => { + + try { + const endpoint = muted ? 'unsilence_callout' : 'silence_callout'; + + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ level: 1}), + }); + + if (!res.ok) {throw new Error(`Request failed with status ${res.status}`);} + setMuted(!muted); + } catch (err) { + console.error("Callout toggle failed: ", err) + alert("Failed to toggle callouts. Is the backend running?") + } +}; + const location = useLocation(); const getNavLinkClass = (path) => @@ -21,6 +42,9 @@ const NavBar = () => {