Merge pull request 'feat: sound effects (Issue #457)' (#460) from sl-mechanical/issue-457-sound-effects into main

This commit is contained in:
sl-jetson 2026-03-05 11:07:11 -05:00
commit f47b01eff6
8 changed files with 600 additions and 0 deletions

View File

@ -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

View File

@ -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])

View File

@ -0,0 +1,27 @@
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>saltybot_sound_effects</name>
<version>0.1.0</version>
<description>
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.
</description>
<maintainer email="sl-mechanical@saltylab.local">sl-mechanical</maintainer>
<license>MIT</license>
<depend>rclpy</depend>
<depend>std_msgs</depend>
<buildtool_depend>ament_python</buildtool_depend>
<test_depend>ament_copyright</test_depend>
<test_depend>ament_flake8</test_depend>
<test_depend>ament_pep257</test_depend>
<test_depend>python3-pytest</test_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>

View File

@ -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()

View File

@ -0,0 +1,4 @@
[develop]
script_dir=$base/lib/saltybot_sound_effects
[bdist_wheel]
universal=0

View File

@ -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",
],
},
)