Files
StarPilot/openpilot/starpilot/audio/sound_pack.py
T
2026-10-25 23:11:00 -05:00

354 lines
14 KiB
Python

"""Installed alert WAV packs."""
from pathlib import Path
import re
import os
import stat
import wave
import numpy as np
PACK_ROOT = Path("/data/themes/theme_packs")
DEFAULT_PACK = "starpilot"
BUILTIN_FILES = {
"engage.wav": "engage.wav", "disengage.wav": "disengage.wav",
"refuse.wav": "warning_1.wav", "warning.wav": "warning_1.wav", "pre_alert.wav": "warning_1.wav",
"dm_warning.wav": "warning_2.wav", "critical.wav": "warning_2.wav", "dm_critical.wav": "warning_3.wav",
}
REMOVED_PACKS = frozenset(("frog", "frogpilot", "stalin"))
MAX_WAV_BYTES = 48000 * 2 * 30 + 4096
LEGACY_NAMES = {
"warning.wav": "prompt.wav", "dm_warning.wav": "prompt_distracted.wav",
"critical.wav": "warning_soft.wav", "dm_critical.wav": "warning_immediate.wav",
}
def pack_directory(name: str, root: Path = PACK_ROOT) -> Path | None:
if name in (DEFAULT_PACK, "stock") or name.casefold() in REMOVED_PACKS or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_~.-]{0,127}", name):
return None
directory = root / name / "sounds"
try:
if any(path.is_symlink() for path in (root, root / name, directory)) or not directory.is_dir():
return None
return directory
except OSError:
return None
def installed_packs(root: Path = PACK_ROOT) -> tuple[str, ...]:
try:
return (DEFAULT_PACK, "stock") + tuple(sorted(path.name for path in root.iterdir() if pack_directory(path.name, root) is not None))
except OSError:
return (DEFAULT_PACK, "stock")
def read_selection(params) -> tuple[bytes | None, str, bool]:
try:
try:
with Path(params.get_param_path("SoundPack")).open("rb") as source:
raw = source.read(129)
except FileNotFoundError:
raw = None
if raw is not None and len(raw) > 128:
return raw, DEFAULT_PACK, False
try:
name = DEFAULT_PACK if raw is None else raw.decode("utf-8")
except UnicodeError:
return raw, DEFAULT_PACK, True
if name.casefold() in REMOVED_PACKS:
name = DEFAULT_PACK
valid = name in (DEFAULT_PACK, "stock") or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_~.-]{0,127}", name) is not None
return raw, name if valid else DEFAULT_PACK, True
except (OSError, KeyError, TypeError, ValueError, UnicodeError):
return None, DEFAULT_PACK, False
def migrate_removed_selection(params, root: Path = PACK_ROOT) -> bool:
"""Persist the fork default when a saved pack no longer identifies a valid choice."""
from openpilot.starpilot.saved_document import commit_exact
raw, name, readable = read_selection(params)
if not readable or raw == DEFAULT_PACK.encode():
return False
legacy_default = raw is None or raw.strip().lower() in (b"", b"default", b"frog", b"frogpilot")
valid_choice = name == "stock" or pack_directory(name, root) is not None
if not legacy_default and valid_choice:
return False
return commit_exact(params, key="SoundPack", max_bytes=128, raw=DEFAULT_PACK.encode(), expected=raw,
authorized=lambda: True, temp_prefix=".sound-pack-migration-").verified
def cleanup_removed_packs(root: Path = PACK_ROOT, *, parked) -> tuple[str, ...]:
"""Bounded parked migration over captured directory descriptors.
Enumerate at most 256 root entries and 64 pack entries, depth 4 / 96 MiB.
Keep captured descriptors, never follow links, and check entry identities
before mutations. External renames between check and syscall remain a POSIX
race; symlink targets are never traversed.
Concurrent growth/revocation leaves remaining obsolete files for a later retry.
"""
def safe():
try:
return bool(parked())
except Exception:
return False
def inode(value):
return value.st_dev, value.st_ino
def revision(value):
return inode(value), value.st_mtime_ns, value.st_ctime_ns
removed, opened = [], []
try:
if not safe() or root.is_symlink():
return ()
root_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
opened.append(root_fd)
root_identity = inode(os.fstat(root_fd))
names = []
with os.scandir(root_fd) as entries:
for entry in entries:
if len(names) == 256:
return ()
names.append(entry.name)
for name in names:
if name.casefold() not in REMOVED_PACKS or not safe():
continue
expected = os.stat(name, dir_fd=root_fd, follow_symlinks=False)
def guard(name=name, expected=expected):
return (safe() and inode(root.lstat()) == root_identity and
inode(os.stat(name, dir_fd=root_fd, follow_symlinks=False)) == inode(expected))
if stat.S_ISLNK(expected.st_mode):
if guard():
os.unlink(name, dir_fd=root_fd)
removed.append(name)
continue
if not stat.S_ISDIR(expected.st_mode):
continue
pack_fds, budget = [], [0, 0]
try:
pack_fd = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=root_fd)
pack_fds.append(pack_fd)
if inode(os.fstat(pack_fd)) != inode(expected):
continue
def plan(directory_fd, depth, *, budget=budget, pack_fds=pack_fds, guard=guard):
if depth > 4 or not guard():
raise ValueError('cleanup depth or authority')
node = [directory_fd, revision(os.fstat(directory_fd)), []]
with os.scandir(directory_fd) as entries:
for entry in entries:
budget[0] += 1
if budget[0] > 64:
raise ValueError('cleanup entry bound')
saved = os.stat(entry.name, dir_fd=directory_fd, follow_symlinks=False)
child = None
if stat.S_ISDIR(saved.st_mode):
child_fd = os.open(entry.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory_fd)
pack_fds.append(child_fd)
if inode(os.fstat(child_fd)) != inode(saved):
raise ValueError('cleanup inode changed')
child = plan(child_fd, depth + 1)
elif not (stat.S_ISREG(saved.st_mode) or stat.S_ISLNK(saved.st_mode)):
raise ValueError('cleanup unexpected entry')
else:
budget[1] += saved.st_size
if budget[1] > 96 * 1024 * 1024:
raise ValueError('cleanup byte bound')
node[2].append((entry.name, saved, child))
if revision(os.fstat(directory_fd)) != node[1]:
raise ValueError('cleanup directory changed')
return node
tree = plan(pack_fd, 1)
def same_entries(directory_fd, expected_names):
observed = set()
with os.scandir(directory_fd) as entries:
for entry in entries:
if len(observed) == 64:
return False
observed.add(entry.name)
return observed == expected_names
def remove(node, *, guard=guard):
directory_fd, _, children = node
for index, (entry_name, saved, child) in enumerate(children):
expected_names = {item[0] for item in children[index:]}
if (not guard() or not same_entries(directory_fd, expected_names) or
revision(os.fstat(directory_fd)) != node[1]):
raise ValueError('cleanup authority or growth')
current = os.stat(entry_name, dir_fd=directory_fd, follow_symlinks=False)
if revision(current) != revision(saved):
raise ValueError('cleanup replacement entry')
if child is not None:
remove(child)
if not guard() or inode(os.stat(entry_name, dir_fd=directory_fd, follow_symlinks=False)) != inode(saved):
raise ValueError('cleanup replacement directory')
os.rmdir(entry_name, dir_fd=directory_fd)
else:
os.unlink(entry_name, dir_fd=directory_fd)
node[1] = revision(os.fstat(directory_fd))
if (not guard() or not same_entries(directory_fd, set()) or
revision(os.fstat(directory_fd)) != node[1]):
raise ValueError('cleanup authority or growth')
remove(tree)
if guard():
os.rmdir(name, dir_fd=root_fd)
removed.append(name)
except (OSError, ValueError):
pass
finally:
for descriptor in reversed(pack_fds):
os.close(descriptor)
except (OSError, TypeError, ValueError):
pass
finally:
for descriptor in reversed(opened):
os.close(descriptor)
return tuple(removed)
def read_wav(path: Path) -> np.ndarray:
if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_WAV_BYTES:
raise ValueError("invalid WAV file")
with wave.open(str(path), "rb") as source:
frames = source.getnframes()
if (source.getnchannels(), source.getsampwidth(), source.getframerate(), source.getcomptype()) != (1, 2, 48000, "NONE"):
raise ValueError("invalid WAV format")
if not 0 < frames <= 48000 * 30:
raise ValueError("invalid WAV duration")
raw = source.readframes(frames)
if len(raw) != frames * 2:
raise ValueError("truncated WAV")
result = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / (2**16/2)
result.flags.writeable = False
return result
def tici_speaker_samples(samples: np.ndarray) -> np.ndarray:
"""Reduce bass excursion in built-in tones, once at load time."""
taps = np.arange(513, dtype=np.float64) - 256
lowpass = np.sinc(2 * 650 / 48000 * taps) * np.hamming(len(taps)) # codespell:ignore sinc
lowpass /= lowpass.sum()
bass = np.convolve(samples, lowpass, mode="full")[256:256 + len(samples)]
result = samples.astype(np.float64) - 0.75 * bass
fade_frames = min(240, len(samples) // 2)
if fade_frames:
fade = 0.5 - 0.5 * np.cos(np.linspace(0, np.pi, fade_frames))
result[:fade_frames] *= fade
result[-fade_frames:] *= fade[::-1]
# Filtering must never add peak demand or normalize quiet clips upward.
original_peak = float(np.max(np.abs(samples)))
filtered_peak = float(np.max(np.abs(result)))
if filtered_peak > original_peak:
result *= original_peak / filtered_peak
result = result.astype(np.float32).astype(np.float64)
prior_peak = float(np.max(np.abs(result)))
taps = np.arange(2049, dtype=np.float64) - 1024
window = np.hamming(len(taps))
band = (2 * 540 / 48000 * np.sinc(2 * 540 / 48000 * taps) - # codespell:ignore sinc
2 * 310 / 48000 * np.sinc(2 * 310 / 48000 * taps)) * window # codespell:ignore sinc
result -= 0.85 * np.convolve(result, band, mode="full")[1024:1024 + len(result)]
if fade_frames:
result[:fade_frames] *= fade
result[-fade_frames:] *= fade[::-1]
peak = float(np.max(np.abs(result)))
if peak > prior_peak:
result *= prior_peak / peak
output = result.astype(np.float32)
output.flags.writeable = False
return output
def tici_disengage_samples(samples: np.ndarray) -> np.ndarray:
"""Lighten the existing disengage chime at load time, preserving its timing."""
taps = np.arange(2049, dtype=np.float64) - 1024
lowpass = 2 * 700 / 48000 * np.sinc(2 * 700 / 48000 * taps) * np.hamming(len(taps)) # codespell:ignore sinc
lowpass /= lowpass.sum()
bass = np.convolve(samples, lowpass, mode="full")[1024:1024 + len(samples)]
result = samples.astype(np.float64) - 0.65 * bass
elapsed = np.arange(len(samples)) / 48000
result *= np.exp(-np.maximum(elapsed - 0.25, 0))
fade_frames = min(240, len(samples) // 2)
if fade_frames:
fade = 0.5 - 0.5 * np.cos(np.linspace(0, np.pi, fade_frames))
result[:fade_frames] *= fade
result[-fade_frames:] *= fade[::-1]
original_peak = float(np.max(np.abs(samples)))
filtered_peak = float(np.max(np.abs(result)))
if filtered_peak > original_peak:
result *= original_peak / filtered_peak
output = result.astype(np.float32)
output.flags.writeable = False
return output
class SoundPackLoader:
def __init__(self, params, stock: Path, root: Path = PACK_ROOT, *, device_type: str = ""):
self.device_type = device_type
self.params, self.stock, self.root = params, stock, root
self.signature = None
self.checked_at = float("-inf")
self.stock_sounds: dict[str, np.ndarray] = {}
self.builtin_sounds: dict[str, np.ndarray] = {}
def is_builtin(self, samples: np.ndarray) -> bool:
return any(samples is loaded for loaded in self.builtin_sounds.values())
def refresh(self, filenames: tuple[str, ...], now: float) -> dict[str, np.ndarray] | None:
if 0 <= now - self.checked_at < 1.0:
return None
self.checked_at = now
raw, name, readable = read_selection(self.params)
directory = pack_directory(name, self.root) if readable else None
paths = tuple(directory / candidate for filename in filenames
for candidate in dict.fromkeys((LEGACY_NAMES.get(filename, filename), filename))) if directory else ()
paths += tuple(self.stock.parent / "sounds_starpilot" / BUILTIN_FILES[filename]
for filename in filenames if filename in BUILTIN_FILES)
metadata = []
for path in paths:
try:
stat = path.lstat()
metadata.append((stat.st_ino, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns))
except OSError:
metadata.append(None)
signature = (raw, directory, filenames, tuple(metadata))
if signature == self.signature:
return None
result = {}
builtin_loaded = {}
for filename in filenames:
selected = None
if (name == DEFAULT_PACK or not readable) and filename in BUILTIN_FILES:
builtin = BUILTIN_FILES[filename]
try:
if builtin not in builtin_loaded:
builtin_loaded[builtin] = read_wav(self.stock.parent / "sounds_starpilot" / builtin)
if self.device_type == "tici":
builtin_loaded[builtin] = tici_speaker_samples(builtin_loaded[builtin])
if builtin == "disengage.wav":
builtin_loaded[builtin] = tici_disengage_samples(builtin_loaded[builtin])
selected = builtin_loaded[builtin]
self.builtin_sounds[builtin] = selected
except (OSError, EOFError, ValueError, wave.Error):
# A damaged optional pack must not stop working stock alerts.
pass
if selected is None:
if filename not in self.stock_sounds:
self.stock_sounds[filename] = read_wav(self.stock / filename)
selected = self.stock_sounds[filename]
if directory:
for candidate in dict.fromkeys((LEGACY_NAMES.get(filename, filename), filename)):
try:
selected = read_wav(directory / candidate)
break
except (OSError, EOFError, ValueError, wave.Error):
pass
result[filename] = selected
self.signature = signature
return result