Files
StarPilot/openpilot/starpilot/audio/native_playback.py
T
2026-10-24 21:46:43 -05:00

251 lines
11 KiB
Python

"""Stream-owned native sample delivery; Python retains all alert and settings decisions."""
from __future__ import annotations
import ctypes
from pathlib import Path
import numpy as np
from openpilot.starpilot.audio.alert_volume import effective_volume
LIBRARY = Path(__file__).with_name('native') / 'libaudio_mixer.so'
TIMING_HEADER = ('abi', 'row_words', 'capacity', 'total_recorded', 'first_underflow', 'frozen',
'start_ordinal', 'row_count', 'callback_aborts', 'clock_error_rows')
TIMING_COLUMNS = ('ordinal', 'entry_mono_ns', 'entry_cpu_ns', 'exit_mono_ns', 'exit_cpu_ns',
'pa_input_bits', 'pa_current_bits', 'pa_output_bits', 'frames', 'flags', 'command',
'restart', 'slot', 'cursor', 'bank_revision', 'status_callbacks', 'underflows',
'last_flags', 'clock_error_mask', 'timeinfo_present', 'pending_bank', 'retired_bank')
# sounddevice marks its pointer closed even when Pa_CloseStream returns an error.
# Keep code, userdata and PCM alive until process exit if that fence fails.
_unclosed_streams = []
class NativePlayback:
def __init__(self, owner, sd, library=LIBRARY):
from openpilot.selfdrive.ui.soundd import ALERT_VOLUME_KEYS, AudibleAlert, SAMPLE_BUFFER, SAMPLE_RATE, STARPILOT_AUTO_MIN, sound_list
self.owner, self.sd = owner, sd
self.volume_keys, self.specs = ALERT_VOLUME_KEYS, sound_list
self.critical_alerts = (AudibleAlert.warningSoft, AudibleAlert.warningImmediate)
self.buffer, self.rate, self.auto_min = SAMPLE_BUFFER, SAMPLE_RATE, STARPILOT_AUTO_MIN
self.keys = tuple(sound_list)
if len(self.keys) > 16:
raise ValueError('Too many native sound slots')
self.slots = {key: i for i, key in enumerate(self.keys)}
self.lib = ctypes.CDLL(str(library))
self.lib.audio_mixer_abi.restype = ctypes.c_uint
if self.lib.audio_mixer_abi() != 3:
raise ValueError('Native audio ABI mismatch')
self.lib.audio_create.restype = ctypes.c_void_p
self.lib.audio_callback_address.restype = ctypes.c_size_t
self.lib.audio_can_publish.argtypes = [ctypes.c_void_p]
self.lib.audio_can_publish.restype = ctypes.c_int
self.lib.audio_collect.argtypes = [ctypes.c_void_p]
self.lib.audio_prepare.argtypes = [ctypes.c_void_p]
self.lib.audio_prepare.restype = ctypes.c_int
self.lib.audio_publish_bank.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.POINTER(ctypes.c_float)),
ctypes.POINTER(ctypes.c_uint), ctypes.c_uint, ctypes.c_uint64]
self.lib.audio_publish_bank.restype = ctypes.c_int
self.lib.audio_command.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_uint, ctypes.c_int,
ctypes.c_int, ctypes.c_float, ctypes.c_uint64, ctypes.c_uint64, ctypes.c_uint64]
self.lib.audio_command.restype = ctypes.c_uint64
self.lib.audio_status.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint64)]
self.lib.audio_status.restype = ctypes.c_int
self.lib.audio_destroy.argtypes = [ctypes.c_void_p]
self.handle = self.lib.audio_create()
if not self.handle:
raise MemoryError('Native audio allocation failed')
self._close_failed = False
self.signature = None
self.sounds = {}
self.builtin = {}
self.bank_sequence = 0
self.restart = self.command_sequence = 0
self.last_command = None
self.selection_sequence = 0
self.stop_frame = 0
self.previous_alert = self.previous_sound = None
self.status_count = 0
self.timing_reported = False
self.timing_getter = getattr(self.lib, 'audio_diagnostic_snapshot', None)
if self.timing_getter is not None:
self.timing_getter.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint64), ctypes.c_uint]
self.timing_getter.restype = ctypes.c_uint
try:
if not self.publish():
raise ValueError('Native sounds were not admitted')
if not self.lib.audio_prepare(self.handle):
raise ValueError('Native initial bank was not admitted')
except BaseException:
self.close()
raise
def publish(self):
owner = self.owner
# Publish a newly selected safety alert using the last admitted bank before
# optional validation/copying of a replacement pack.
if self.sounds:
self.publish_command()
signature = tuple(id(owner.loaded_sounds[key]) for key in self.keys)
if signature != self.signature and self.lib.audio_can_publish(self.handle):
arrays = [np.ascontiguousarray(owner.loaded_sounds[key], dtype=np.float32) for key in self.keys]
if any(a.ndim != 1 or not 0 < len(a) <= 1_440_000 or not np.isfinite(a).all() for a in arrays):
raise ValueError('Invalid native waveform')
pointers = (ctypes.POINTER(ctypes.c_float) * len(arrays))(
*(a.ctypes.data_as(ctypes.POINTER(ctypes.c_float)) for a in arrays))
lengths = (ctypes.c_uint * len(arrays))(*(len(a) for a in arrays))
if self.lib.audio_publish_bank(self.handle, pointers, lengths, len(arrays), self.bank_sequence + 1):
self.bank_sequence += 1
self.signature = signature
self.builtin = {key: owner.pack_loader.is_builtin(owner.loaded_sounds[key]) for key in self.keys}
self.sounds = dict(owner.loaded_sounds)
if not self.sounds:
return False
self.lib.audio_collect(self.handle)
self.publish_command()
return True
def publish_command(self):
owner = self.owner
alert = owner.current_alert
sound = owner.current_sound
if (alert != self.previous_alert or sound != self.previous_sound) and owner.current_sound_frame == 0:
self.restart += 1
self.previous_alert, self.previous_sound = alert, sound
slot = self.slots.get(sound, 0)
loops = self.specs[alert][1] if alert else None
gain = owner.current_volume
samples = self.sounds.get(sound)
if samples is not None and self.builtin.get(sound, False):
gain = max(gain, self.auto_min)
key = self.volume_keys.get(alert)
if key is not None:
gain = effective_volume(key, owner.saved_volumes.get(key), gain,
immediate_ramp=owner.current_volume if alert in self.critical_alerts else None)
length = len(samples) if samples is not None else 1
if owner.pending_stop:
if not self.stop_frame or (self.last_command is not None and self.last_command[6] != self.bank_sequence):
self.stop_frame = (owner.current_sound_frame // length + 1) * length
else:
self.stop_frame = 0
command = (int(alert), slot, -1 if loops is None else loops, int(owner.pending_stop),
gain, self.restart, self.bank_sequence, self.stop_frame)
if command == self.last_command:
return
sequence = self.lib.audio_command(self.handle, *command)
if not sequence:
raise ValueError('Invalid native alert command')
self.command_sequence = sequence
if self.last_command is None or command[:4] + command[5:] != self.last_command[:4] + self.last_command[5:]:
self.selection_sequence = sequence
self.last_command = command
def snapshot(self):
values = (ctypes.c_uint64 * 10)()
return list(values) if self.lib.audio_status(self.handle, values) else None
def sync_owner(self):
value = self.snapshot()
if value is None or not value[6]:
return
owner = self.owner
# Volume-only publications must not hide cursor progress. A new selection
# epoch still prevents an old callback from overwriting a newer alert.
if value[8] == self.restart and value[9] == self.slots.get(owner.current_sound, 0):
owner.current_sound_frame = value[0]
if value[7] >= self.selection_sequence:
owner.current_alert, owner.pending_stop = value[1], bool(value[2])
# Native dismissal completion is a selection transition too.
self.previous_alert = owner.current_alert
if value[3] != self.status_count:
owner.pending_stream_status = self.sd.CallbackFlags(value[5])
self.status_count = value[3]
owner.stream_status_count, owner.output_underflow_count = value[3], value[4]
def report_underflow_timing(self, *, closing=False):
"""Read one fixed native trace on the service thread, after four recovery callbacks."""
if self.timing_reported or self.timing_getter is None or (not closing and not self.owner.output_underflow_count):
return
values = (ctypes.c_uint64 * 714)()
words = self.timing_getter(self.handle, values, len(values))
if not words or (not closing and not values[5]):
return
if not values[4] and not values[8]:
return
self.timing_reported = True
from openpilot.common.swaglog import cloudlog
# Integer clock values and raw PortAudio double bits keep invalid clocks
# explicit and make JSON finite. Decode host stream clocks separately.
count = values[7]
if values[0] != 1 or values[1] != 22 or values[2] != 32 or count > 32 or words != 10 + 22 * count:
cloudlog.error({'event': 'soundd_underflow_timing', 'error': 'invalid_native_diagnostic'})
return
cloudlog.error({'event': 'soundd_underflow_timing', 'schema': 1, 'sample_rate': self.rate,
'block_frames': self.buffer, 'header_columns': TIMING_HEADER, 'columns': TIMING_COLUMNS,
'header': list(values[:10]),
'rows': [list(values[i:i + 22]) for i in range(10, words, 22)]})
def stream(self):
# Public OutputStream/RawOutputStream wrap callbacks in Python; only the existing base CData path avoids the GIL.
callback = self.sd._ffi.cast('PaStreamCallback*', self.lib.audio_callback_address())
userdata = self.sd._ffi.cast('void*', self.handle)
base = self.sd._OutputStreamBase
stream = base.__new__(base)
wrapper = NativeStream(stream, self)
try:
base.__init__(stream, kind='output', wrap_callback=None, channels=1, dtype='float32',
samplerate=self.rate, blocksize=self.buffer, callback=callback, userdata=userdata)
except BaseException as constructor_error:
pointer = getattr(stream, '_ptr', self.sd._ffi.NULL)
# Before a successful open, sounddevice holds PaStream**; after open it
# holds PaStream*. Only the latter requires the explicit close fence.
if self.sd._ffi.typeof(pointer) == self.sd._ffi.typeof('PaStream*') and pointer:
try:
wrapper.close()
except BaseException as close_error:
constructor_error.add_note(f'Native audio close also failed: {close_error}')
raise
return wrapper
def close(self):
if self.handle and not self._close_failed:
self.lib.audio_destroy(self.handle)
self.handle = None
class NativeStream:
def __init__(self, stream, playback):
self.stream, self.playback = stream, playback
def __getattr__(self, name):
return getattr(self.stream, name)
def __enter__(self):
try:
self.stream.start()
except BaseException as start_error:
try:
self.close()
except Exception as close_error:
# The original start failure remains the caller-visible error; record
# the close failure on that exception without freeing stream memory.
start_error.add_note(f'Native audio close also failed: {close_error}')
raise
return self
def __exit__(self, *args):
try:
self.stream.stop(ignore_errors=False)
finally:
self.close()
return False
def close(self):
try:
self.stream.close(ignore_errors=False)
except BaseException:
self.playback._close_failed = True
_unclosed_streams.append(self)
raise
else:
self.playback.close()