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

147 lines
6.3 KiB
Python

from types import SimpleNamespace
from unittest.mock import patch
from openpilot.starpilot.audio.native_playback import NativePlayback
def playback(*, frozen=1, count=21, words=None, underflows=1, clock_errors=0):
obj = NativePlayback.__new__(NativePlayback)
obj.owner = SimpleNamespace(output_underflow_count=underflows)
obj.timing_reported = False
obj.handle, obj.rate, obj.buffer = 1, 48000, 4096
calls = []
def read(handle, values, capacity):
calls.append((handle, capacity))
header = (1, 22, 32, 25, 21, frozen, 5, count, 0, clock_errors)
for index, value in enumerate(header):
values[index] = value
for row in range(min(count, 32)):
values[10 + row * 22] = 5 + row
return 10 + 22 * count if words is None else words
obj.timing_getter = read
return obj, calls
def test_healthy_or_old_native_library_does_not_poll_or_log():
obj, calls = playback(underflows=0)
with patch('openpilot.common.swaglog.cloudlog.error') as error:
obj.report_underflow_timing()
obj.owner.output_underflow_count = 1
obj.timing_getter = None
obj.report_underflow_timing()
assert not calls
error.assert_not_called()
def test_waits_for_recovery_then_logs_one_bounded_trace():
obj, calls = playback(frozen=0)
with patch('openpilot.common.swaglog.cloudlog.error') as error:
obj.report_underflow_timing()
error.assert_not_called()
assert not obj.timing_reported
obj.timing_getter = playback()[0].timing_getter
obj.report_underflow_timing()
obj.report_underflow_timing()
assert len(calls) == 1
error.assert_called_once()
trace = error.call_args.args[0]
assert trace['event'] == 'soundd_underflow_timing' and trace['schema'] == 1
assert trace['sample_rate'] == 48000 and trace['block_frames'] == 4096
assert len(trace['columns']) == 22 and trace['columns'][1:5] == ('entry_mono_ns', 'entry_cpu_ns', 'exit_mono_ns', 'exit_cpu_ns')
assert len(trace['header_columns']) == 10 and trace['header_columns'][8] == 'callback_aborts'
assert trace['header'] == [1, 22, 32, 25, 21, 1, 5, 21, 0, 0]
assert len(trace['rows']) == 21 and all(len(row) == 22 for row in trace['rows'])
assert [row[0] for row in trace['rows']] == list(range(5, 26))
def test_unavailable_snapshot_retries_and_clock_errors_remain_explicit():
obj, calls = playback(words=0)
with patch('openpilot.common.swaglog.cloudlog.error') as error:
obj.report_underflow_timing()
assert not obj.timing_reported
obj.timing_getter = playback(clock_errors=3)[0].timing_getter
obj.report_underflow_timing()
assert len(calls) == 1
assert error.call_args.args[0]['header'][9] == 3
def test_invalid_native_metadata_is_an_error_without_unbounded_rows():
for count, words in ((33, 736), (21, 999)):
obj, _ = playback(count=count, words=words)
with patch('openpilot.common.swaglog.cloudlog.error') as error:
obj.report_underflow_timing()
obj.report_underflow_timing()
error.assert_called_once_with({'event': 'soundd_underflow_timing', 'error': 'invalid_native_diagnostic'})
def test_closing_stream_preserves_partial_underflow_trace():
obj, _ = playback(frozen=0)
with patch('openpilot.common.swaglog.cloudlog.error') as error:
obj.report_underflow_timing(closing=True)
error.assert_called_once()
assert error.call_args.args[0]['header'][5] == 0
assert error.call_args.args[0]['rows']
def test_closing_stream_reports_native_abort_without_claiming_an_underflow():
obj, _ = playback(frozen=0, underflows=0)
read = obj.timing_getter
def aborted(handle, values, capacity):
words = read(handle, values, capacity)
values[4], values[8] = 0, 1
return words
obj.timing_getter = aborted
with patch('openpilot.common.swaglog.cloudlog.error') as error:
obj.report_underflow_timing(closing=True)
header = error.call_args.args[0]['header']
assert header[4] == 0 and header[8] == 1
def test_compiled_native_trace_reaches_service_logger_with_exact_pcm(tmp_path):
import ctypes
import subprocess
from pathlib import Path
source = Path(__file__).parents[1] / 'native/audio_mixer.c'
library = tmp_path / 'mixer.so'
subprocess.run(['cc', '-std=c11', '-O2', '-shared', '-fPIC', str(source), '-o', str(library)],
check=True, capture_output=True, timeout=15)
lib = ctypes.CDLL(str(library))
lib.audio_create.restype = ctypes.c_void_p
lib.audio_destroy.argtypes = [ctypes.c_void_p]
lib.audio_callback_address.restype = ctypes.c_size_t
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]
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]
lib.audio_prepare.argtypes = [ctypes.c_void_p]
lib.audio_diagnostic_snapshot.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint64), ctypes.c_uint]
lib.audio_diagnostic_snapshot.restype = ctypes.c_uint
obj, _ = playback()
obj.handle = lib.audio_create()
assert obj.handle
obj.timing_getter = lib.audio_diagnostic_snapshot
samples = (ctypes.c_float * 4)(.25, -.5, .75, -1.)
bank = (ctypes.POINTER(ctypes.c_float) * 1)(samples)
lengths = (ctypes.c_uint * 1)(4)
output = (ctypes.c_float * 4)()
callback = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_ulong,
ctypes.c_void_p, ctypes.c_ulong, ctypes.c_void_p)(lib.audio_callback_address())
try:
assert lib.audio_publish_bank(obj.handle, bank, lengths, 1, 1)
assert lib.audio_command(obj.handle, 1, 0, -1, 0, .5, 1, 1, 0)
assert lib.audio_prepare(obj.handle)
with patch('openpilot.common.swaglog.cloudlog.error') as error:
for index in range(25):
assert callback(None, output, 4, None, 4 if index == 20 else 0, obj.handle) == 0
assert list(output) == [.125, -.25, .375, -.5]
obj.report_underflow_timing()
obj.report_underflow_timing()
error.assert_called_once()
trace = error.call_args.args[0]
assert trace['header'][:8] == [1, 22, 32, 25, 21, 1, 5, 21]
assert trace['rows'][16][9] == 4 and trace['rows'][16][16] == 1
assert all(row[19] == 0 for row in trace['rows']) # Missing host clock metadata remains explicit.
finally:
lib.audio_destroy(obj.handle)