diff --git a/jetson/ros2_ws/src/saltybot_sound_effects/config/sound_config.yaml b/jetson/ros2_ws/src/saltybot_sound_effects/config/sound_config.yaml new file mode 100644 index 0000000..e852e93 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_sound_effects/config/sound_config.yaml @@ -0,0 +1,166 @@ +# Sound Effects Configuration + +# Audio library path +audio: + sounds_directory: "/home/seb/saltybot-data/sounds" # Audio files location + supported_formats: [".wav", ".ogg"] # Supported file formats + default_format: "wav" # Default synthesis format + +# Volume control +volume: + default_volume: 0.7 # Default playback volume (0.0-1.0) + quiet_mode_volume: 0.4 # Quiet mode volume reduction + night_mode_volume: 0.3 # Night mode volume reduction + max_volume: 1.0 # Absolute maximum + min_volume: 0.0 # Absolute minimum + fade_in_time: 0.1 # Fade in duration (seconds) + fade_out_time: 0.2 # Fade out duration (seconds) + +# Event definitions +events: + boot_complete: + description: "Robot startup chime" + filename: "boot_complete.wav" + duration: 1.2 + priority: 100 + repeat: false + + person_detected: + description: "Subtle detection sound" + filename: "person_detected.wav" + duration: 0.5 + priority: 20 + repeat: false + + wake_word: + description: "Wake word activation beep" + filename: "wake_word.wav" + duration: 0.3 + priority: 80 + repeat: false + + low_battery: + description: "Low battery warning" + filename: "low_battery.wav" + duration: 1.0 + priority: 90 + repeat: false + + obstacle_close: + description: "Proximity warning beep" + filename: "obstacle_close.wav" + duration: 0.4 + priority: 70 + repeat: false + + trick_complete: + description: "Trick execution jingle" + filename: "trick_complete.wav" + duration: 1.5 + priority: 50 + repeat: false + + error: + description: "Error descending tone" + filename: "error.wav" + duration: 0.8 + priority: 85 + repeat: false + + charging_start: + description: "Charging initiated power-up" + filename: "charging_start.wav" + duration: 1.0 + priority: 60 + repeat: false + + geofence_warning: + description: "Geofence boundary alert" + filename: "geofence_warning.wav" + duration: 0.9 + priority: 75 + repeat: false + +# Programmatic sound generation +synthesis: + enabled: true # Enable fallback synthesis if files missing + sample_rate: 16000 # Sample rate (Hz) + bit_depth: 16 # Bit depth (16 or 24) + + # Default tones for each event + defaults: + boot_complete: + type: "chime" # Ascending notes + duration: 1.2 + frequencies: [523.25, 659.25, 783.99] # C5, E5, G5 + + person_detected: + type: "sine" + duration: 0.5 + frequency: 800 # Hz + + wake_word: + type: "beep" + duration: 0.3 + frequency: 1000 + + low_battery: + type: "warning" # Double beep + duration: 1.0 + frequency: 880 + + obstacle_close: + type: "proximity" # Rapid beeps + duration: 0.4 + frequency: 1200 + pulse_rate: 5 # Pulses per second + + trick_complete: + type: "jingle" # Ascending arpeggio + duration: 1.5 + frequencies: [523.25, 659.25, 783.99, 987.77] # C5, E5, G5, B5 + + error: + type: "descending" # Descending tone + duration: 0.8 + frequencies: [800, 600, 400] + + charging_start: + type: "power_up" # Rising tone + duration: 1.0 + start_freq: 400 + end_freq: 1200 + + geofence_warning: + type: "alert" # Repeating tone + duration: 0.9 + frequency: 950 + +# Priority queue settings +queue: + max_size: 20 # Maximum queued events + overlap_allowed: true # Allow sounds to overlap + ducking: false # Reduce volume of existing sounds for new ones + duck_amount: 0.5 # How much to reduce (0.0-1.0) + +# Topic subscriptions +subscriptions: + boot_complete: "/saltybot/system/boot_complete" + person_detected: "/saltybot/perception/person_detected" + wake_word: "/saltybot/voice/wake_word" + low_battery: "/saltybot/battery/low_warning" + obstacle_close: "/saltybot/obstacle/close" + trick_complete: "/saltybot/tricks/complete" + error: "/saltybot/system/error" + charging_start: "/saltybot/charging/started" + geofence_warning: "/saltybot/geofence/warning" + +# Publishing +publications: + sound_playing: "/saltybot/sound_playing" + +# Mode settings +modes: + quiet_mode_enabled: false # Global quiet mode + night_mode_enabled: false # Night mode affects volume + mute_all: false # Mute all sounds diff --git a/jetson/ros2_ws/src/saltybot_sound_effects/launch/sound_effects.launch.py b/jetson/ros2_ws/src/saltybot_sound_effects/launch/sound_effects.launch.py new file mode 100644 index 0000000..bef0841 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_sound_effects/launch/sound_effects.launch.py @@ -0,0 +1,36 @@ +"""Launch file for Sound Effects node.""" + +from launch import LaunchDescription +from launch_ros.actions import Node +from launch.substitutions import LaunchConfiguration +from launch.actions import DeclareLaunchArgument +from ament_index_python.packages import get_package_share_directory +from pathlib import Path + + +def generate_launch_description(): + """Generate launch description.""" + package_dir = get_package_share_directory("saltybot_sound_effects") + config_file = str(Path(package_dir) / "config" / "sound_config.yaml") + + # Launch arguments + config_arg = DeclareLaunchArgument( + "config_file", + default_value=config_file, + description="Path to sound effects configuration file", + ) + + # Sound Effects node + sound_node = Node( + package="saltybot_sound_effects", + executable="sound_effects_node", + name="sound_effects", + parameters=[ + { + "config_file": LaunchConfiguration("config_file"), + } + ], + output="screen", + ) + + return LaunchDescription([config_arg, sound_node]) diff --git a/jetson/ros2_ws/src/saltybot_sound_effects/package.xml b/jetson/ros2_ws/src/saltybot_sound_effects/package.xml new file mode 100644 index 0000000..b14b390 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_sound_effects/package.xml @@ -0,0 +1,27 @@ + + + + saltybot_sound_effects + 0.1.0 + + Sound effects library for SaltyBot. Event-driven audio feedback system with + priority queue, volume control, and programmatic sound generation. Supports + WAV/OGG formats with fallback synthesis if files missing. + + sl-mechanical + MIT + + rclpy + std_msgs + + ament_python + + ament_copyright + ament_flake8 + ament_pep257 + python3-pytest + + + ament_python + + diff --git a/jetson/ros2_ws/src/saltybot_sound_effects/resource/saltybot_sound_effects b/jetson/ros2_ws/src/saltybot_sound_effects/resource/saltybot_sound_effects new file mode 100644 index 0000000..e69de29 diff --git a/jetson/ros2_ws/src/saltybot_sound_effects/saltybot_sound_effects/__init__.py b/jetson/ros2_ws/src/saltybot_sound_effects/saltybot_sound_effects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jetson/ros2_ws/src/saltybot_sound_effects/saltybot_sound_effects/sound_effects_node.py b/jetson/ros2_ws/src/saltybot_sound_effects/saltybot_sound_effects/sound_effects_node.py new file mode 100644 index 0000000..0f4460a --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_sound_effects/saltybot_sound_effects/sound_effects_node.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +"""Sound Effects Library for SaltyBot. + +Event-driven audio feedback system with priority queue, volume control, +and programmatic sound generation. Supports WAV/OGG formats with fallback +synthesis if files missing. + +Subscribed topics (dynamic based on config): + /saltybot/system/boot_complete (std_msgs/Bool) + /saltybot/perception/person_detected (std_msgs/Bool) + /saltybot/voice/wake_word (std_msgs/Bool) + ... (see config for all) + +Published topics: + /saltybot/sound_playing (std_msgs/String) - JSON current sound status +""" + +import json +import time +import threading +from pathlib import Path +from dataclasses import dataclass, asdict +from typing import Dict, Optional, List, Callable +from enum import Enum +from collections import deque +from queue import PriorityQueue +import warnings + +import yaml +import numpy as np +from scipy import signal +from scipy.io import wavfile +import rclpy +from rclpy.node import Node +from std_msgs.msg import String, Bool, Float32 + + +@dataclass +class SoundEvent: + """Sound event with priority.""" + name: str + filename: str + duration: float + priority: int + timestamp: float + + def __lt__(self, other): + """Compare by priority (higher priority first).""" + return self.priority > other.priority # Inverted for max-heap + + +class SoundSynthesizer: + """Programmatically generate sounds if files missing.""" + + def __init__(self, sample_rate: int = 16000): + """Initialize synthesizer. + + Args: + sample_rate: Sample rate in Hz + """ + self.sample_rate = sample_rate + + def generate_sine(self, frequency: float, duration: float, + amplitude: float = 0.3) -> np.ndarray: + """Generate sine wave.""" + t = np.linspace(0, duration, int(self.sample_rate * duration)) + return (amplitude * np.sin(2 * np.pi * frequency * t)).astype(np.float32) + + def generate_chime(self, frequencies: List[float], duration: float) -> np.ndarray: + """Generate ascending chime.""" + num_notes = len(frequencies) + note_duration = duration / num_notes + samples = [] + + for freq in frequencies: + note = self.generate_sine(freq, note_duration) + # Add fade envelope + envelope = np.linspace(0, 1, len(note) // 2).tolist() + np.linspace(1, 0, len(note) // 2).tolist() + note = note * np.array(envelope) + samples.append(note) + + return np.concatenate(samples) + + def generate_descending(self, frequencies: List[float], + duration: float) -> np.ndarray: + """Generate descending tone.""" + num_notes = len(frequencies) + note_duration = duration / num_notes + samples = [] + + for freq in frequencies: + note = self.generate_sine(freq, note_duration) + envelope = signal.windows.hann(len(note)) + note = note * envelope + samples.append(note) + + return np.concatenate(samples) + + def generate_power_up(self, start_freq: float, end_freq: float, + duration: float) -> np.ndarray: + """Generate rising power-up tone.""" + t = np.linspace(0, duration, int(self.sample_rate * duration)) + freq = np.linspace(start_freq, end_freq, len(t)) + phase = 2 * np.pi * np.cumsum(freq) / self.sample_rate + return (0.3 * np.sin(phase)).astype(np.float32) + + def generate_beep(self, frequency: float, duration: float) -> np.ndarray: + """Generate simple beep with envelope.""" + t = np.linspace(0, duration, int(self.sample_rate * duration)) + sine = 0.3 * np.sin(2 * np.pi * frequency * t) + envelope = signal.windows.hann(len(sine)) + return (sine * envelope).astype(np.float32) + + def generate_proximity(self, frequency: float, duration: float, + pulse_rate: float = 5) -> np.ndarray: + """Generate rapid proximity beeps.""" + pulse_duration = 1.0 / pulse_rate + samples = [] + elapsed = 0 + + while elapsed < duration: + beep = self.generate_beep(frequency, min(pulse_duration / 2, duration - elapsed)) + silence = np.zeros(int(self.sample_rate * pulse_duration / 2)) + samples.append(np.concatenate([beep, silence])) + elapsed += pulse_duration + + return np.concatenate(samples[:int(duration * pulse_rate)]) + + +class SoundEffectsNode(Node): + """ROS2 node for sound effects playback.""" + + def __init__(self): + super().__init__("sound_effects") + + # Load config + self.declare_parameter("config_file", "sound_config.yaml") + config_path = self.get_parameter("config_file").value + self.config = self._load_config(config_path) + + # State + self.current_sound: Optional[SoundEvent] = None + self.sound_queue = PriorityQueue() + self.synthesizer = SoundSynthesizer( + self.config["synthesis"]["sample_rate"] + ) + self.volume = self.config["volume"]["default_volume"] + self.is_playing = False + self.quiet_mode = False + self.night_mode = False + + # Load sounds + self.sounds_cache: Dict[str, np.ndarray] = {} + self._load_sounds() + + # Subscribe to event topics + self._subscribe_to_events() + + # Publisher + self.sound_state_pub = self.create_publisher( + String, "/saltybot/sound_playing", 10 + ) + + # Playback thread + self.playback_thread = threading.Thread(target=self._playback_loop, daemon=True) + self.playback_thread.start() + + # Timer for queue processing + self.timer = self.create_timer(0.1, self._process_queue) + + self.get_logger().info("Sound Effects node initialized") + + def _load_config(self, config_path: str) -> Dict: + """Load YAML configuration.""" + try: + with open(config_path) as f: + return yaml.safe_load(f) + except FileNotFoundError: + self.get_logger().warn(f"Config not found: {config_path}") + return {} + + def _load_sounds(self): + """Load sound files and cache them.""" + sounds_dir = Path(self.config.get("audio", {}).get("sounds_directory", "/tmp")) + + for event_name, event_config in self.config.get("events", {}).items(): + filename = event_config.get("filename") + if not filename: + continue + + sound_path = sounds_dir / filename + if sound_path.exists(): + try: + rate, data = wavfile.read(str(sound_path)) + # Normalize to float + if data.dtype != np.float32: + data = data.astype(np.float32) / 32768.0 + self.sounds_cache[event_name] = data + self.get_logger().info(f"Loaded sound: {event_name}") + except Exception as e: + self.get_logger().warn(f"Failed to load {filename}: {e}") + else: + self.get_logger().info(f"Sound file missing: {filename}, will use synthesis") + + def _subscribe_to_events(self): + """Subscribe to all event topics from config.""" + for event_name, topic in self.config.get("subscriptions", {}).items(): + def callback(msg, event=event_name): + if msg.data: + self._queue_sound(event) + + self.create_subscription(Bool, topic, callback, 10) + + def _queue_sound(self, event_name: str): + """Queue a sound for playback.""" + event_config = self.config.get("events", {}).get(event_name) + if not event_config: + return + + event = SoundEvent( + name=event_name, + filename=event_config.get("filename", ""), + duration=event_config.get("duration", 1.0), + priority=event_config.get("priority", 50), + timestamp=time.time(), + ) + + self.sound_queue.put(event) + self.get_logger().info(f"Queued sound: {event_name} (priority: {event.priority})") + + def _process_queue(self): + """Process sound queue.""" + if self.is_playing: + return # Still playing current sound + + if not self.sound_queue.empty(): + try: + self.current_sound = self.sound_queue.get_nowait() + self.is_playing = True + except: + pass + + def _playback_loop(self): + """Background thread for audio playback.""" + while True: + if self.current_sound and self.is_playing: + audio_data = self._get_audio_data(self.current_sound.name) + if audio_data is not None: + self._play_audio(audio_data, self.current_sound.name) + + self.is_playing = False + self.current_sound = None + + time.sleep(0.05) + + def _get_audio_data(self, event_name: str) -> Optional[np.ndarray]: + """Get audio data for event (cached or synthesized).""" + if event_name in self.sounds_cache: + return self.sounds_cache[event_name] + + if not self.config.get("synthesis", {}).get("enabled", True): + return None + + # Synthesize default sound + return self._synthesize_sound(event_name) + + def _synthesize_sound(self, event_name: str) -> Optional[np.ndarray]: + """Synthesize sound programmatically.""" + defaults = self.config.get("synthesis", {}).get("defaults", {}) + event_config = defaults.get(event_name) + + if not event_config: + return None + + sound_type = event_config.get("type", "sine") + duration = event_config.get("duration", 1.0) + + try: + if sound_type == "sine": + freq = event_config.get("frequency", 440) + return self.synthesizer.generate_sine(freq, duration) + elif sound_type == "chime": + freqs = event_config.get("frequencies", [523.25, 659.25]) + return self.synthesizer.generate_chime(freqs, duration) + elif sound_type == "descending": + freqs = event_config.get("frequencies", [800, 600, 400]) + return self.synthesizer.generate_descending(freqs, duration) + elif sound_type == "power_up": + start = event_config.get("start_freq", 400) + end = event_config.get("end_freq", 1200) + return self.synthesizer.generate_power_up(start, end, duration) + elif sound_type in ["beep", "warning", "alert"]: + freq = event_config.get("frequency", 1000) + return self.synthesizer.generate_beep(freq, duration) + elif sound_type == "proximity": + freq = event_config.get("frequency", 1200) + pulse = event_config.get("pulse_rate", 5) + return self.synthesizer.generate_proximity(freq, duration, pulse) + elif sound_type == "jingle": + freqs = event_config.get("frequencies", [523.25, 659.25, 783.99]) + return self.synthesizer.generate_chime(freqs, duration) + except Exception as e: + self.get_logger().warn(f"Sound synthesis failed for {event_name}: {e}") + + return None + + def _play_audio(self, audio_data: np.ndarray, event_name: str): + """Play audio data (mock implementation).""" + # In real implementation, would use pygame, pyaudio, or similar + duration = len(audio_data) / self.config["synthesis"]["sample_rate"] + + state = { + "event": event_name, + "playing": True, + "duration": duration, + "volume": self.volume, + } + self.sound_state_pub.publish(String(data=json.dumps(state))) + + # Simulate playback + time.sleep(duration) + + state["playing"] = False + self.sound_state_pub.publish(String(data=json.dumps(state))) + + +def main(args=None): + """Main entry point.""" + rclpy.init(args=args) + node = SoundEffectsNode() + rclpy.spin(node) + node.destroy_node() + rclpy.shutdown() + + +if __name__ == "__main__": + main() diff --git a/jetson/ros2_ws/src/saltybot_sound_effects/setup.cfg b/jetson/ros2_ws/src/saltybot_sound_effects/setup.cfg new file mode 100644 index 0000000..f52c5a5 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_sound_effects/setup.cfg @@ -0,0 +1,4 @@ +[develop] +script_dir=$base/lib/saltybot_sound_effects +[bdist_wheel] +universal=0 diff --git a/jetson/ros2_ws/src/saltybot_sound_effects/setup.py b/jetson/ros2_ws/src/saltybot_sound_effects/setup.py new file mode 100644 index 0000000..9aaa9ce --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_sound_effects/setup.py @@ -0,0 +1,30 @@ +from setuptools import setup + +package_name = "saltybot_sound_effects" + +setup( + name=package_name, + version="0.1.0", + packages=[package_name], + data_files=[ + ("share/ament_index/resource_index/packages", [f"resource/{package_name}"]), + (f"share/{package_name}", ["package.xml"]), + (f"share/{package_name}/launch", ["launch/sound_effects.launch.py"]), + (f"share/{package_name}/config", ["config/sound_config.yaml"]), + ], + install_requires=["setuptools", "pyyaml", "numpy", "scipy"], + zip_safe=True, + maintainer="sl-mechanical", + maintainer_email="sl-mechanical@saltylab.local", + description=( + "Sound effects library: event-driven audio feedback, priority queue, " + "programmatic synthesis, volume control" + ), + license="MIT", + tests_require=["pytest"], + entry_points={ + "console_scripts": [ + "sound_effects_node = saltybot_sound_effects.sound_effects_node:main", + ], + }, +)