mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-02 20:13:53 +08:00
Render alert audio without the Python GIL
This commit is contained in:
@@ -328,6 +328,7 @@ SConscript([
|
||||
'openpilot/selfdrive/locationd/SConscript',
|
||||
'openpilot/selfdrive/modeld/SConscript',
|
||||
'openpilot/selfdrive/ui/SConscript',
|
||||
'openpilot/starpilot/audio/native/SConscript',
|
||||
])
|
||||
|
||||
# Replay is also the isolated on-device viewer engine.
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import math
|
||||
import json
|
||||
import os
|
||||
import numpy as np
|
||||
import time
|
||||
from time import monotonic_ns
|
||||
from threading import get_native_id
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -16,7 +11,7 @@ from openpilot.common.utils import retry
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
|
||||
from openpilot.system import micd
|
||||
from openpilot.common.hardware import HARDWARE
|
||||
from openpilot.common.hardware import HARDWARE, PC
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.starpilot.aol.wire import INTENT_SERVICE
|
||||
from openpilot.starpilot.audio.alert_volume import AUTO, VOLUMES, effective_volume, read_volume
|
||||
@@ -85,93 +80,6 @@ def check_selfdrive_timeout_alert(sm):
|
||||
return False
|
||||
|
||||
|
||||
def _native_audio_time(info, key):
|
||||
try:
|
||||
value = float(getattr(info, key))
|
||||
return value if math.isfinite(value) else None
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _callback_thread_snapshot(tid):
|
||||
snapshot = {"tid": tid}
|
||||
try:
|
||||
directory = Path(f"/proc/self/task/{tid}")
|
||||
with (directory / "stat").open() as source:
|
||||
fields = source.read(4096).rsplit(")", 1)[1].split()
|
||||
with (directory / "schedstat").open() as source:
|
||||
runtime_ns, runqueue_ns, timeslices = map(int, source.read(4096).split())
|
||||
snapshot.update(state=fields[0], priority=int(fields[15]), nice=int(fields[16]), processor=int(fields[36]),
|
||||
runtime_ns=runtime_ns, runqueue_ns=runqueue_ns, timeslices=timeslices,
|
||||
policy=os.sched_getscheduler(tid), affinity=sorted(os.sched_getaffinity(tid)))
|
||||
except (OSError, ValueError, IndexError, AttributeError):
|
||||
snapshot["unavailable"] = True
|
||||
return snapshot
|
||||
|
||||
|
||||
def _read_callback_schedstat(tid):
|
||||
with Path(f"/proc/self/task/{tid}/schedstat").open() as source:
|
||||
values = tuple(map(int, source.read(128).split()))
|
||||
if len(values) != 3 or any(value < 0 for value in values):
|
||||
raise ValueError("invalid callback schedstat")
|
||||
return values
|
||||
|
||||
|
||||
class _CallbackSchedulerHistory:
|
||||
MAX_GAP_NS = 100_000_000
|
||||
|
||||
def __init__(self):
|
||||
self.tid = None
|
||||
self.samples = deque(maxlen=128)
|
||||
|
||||
def sample(self, tid):
|
||||
if tid != self.tid:
|
||||
self.samples.clear()
|
||||
self.tid = tid
|
||||
if tid is None:
|
||||
return
|
||||
try:
|
||||
begin = monotonic_ns()
|
||||
counters = _read_callback_schedstat(tid)
|
||||
end = monotonic_ns()
|
||||
except (OSError, ValueError, IndexError, AttributeError):
|
||||
self.samples.clear()
|
||||
return
|
||||
if end < begin or end - begin > self.MAX_GAP_NS:
|
||||
self.samples.clear()
|
||||
return
|
||||
if self.samples and (begin < self.samples[-1][1] or
|
||||
any(new < old for new, old in zip(counters, self.samples[-1][2], strict=True))):
|
||||
self.samples.clear()
|
||||
self.samples.append((begin, end, counters))
|
||||
|
||||
def bracket(self, tid, event_end, event_gap):
|
||||
unknown = {"available": False}
|
||||
if tid != self.tid or event_gap is None or event_gap <= 0:
|
||||
return unknown
|
||||
event_start = event_end - event_gap
|
||||
before = [i for i, sample in enumerate(self.samples) if sample[1] <= event_start]
|
||||
after = [i for i, sample in enumerate(self.samples) if sample[0] >= event_end]
|
||||
if not before or not after:
|
||||
return unknown
|
||||
window = list(self.samples)[before[-1]:after[0] + 1]
|
||||
if len(window) < 2:
|
||||
return unknown
|
||||
first, last = window[0], window[-1]
|
||||
max_gap = max(right[1] - left[0] for left, right in zip(window, window[1:], strict=False))
|
||||
if (max_gap > self.MAX_GAP_NS or event_start - first[1] > self.MAX_GAP_NS or
|
||||
last[0] - event_end > self.MAX_GAP_NS):
|
||||
return unknown
|
||||
return {"available": True, "tid": tid,
|
||||
"event_gap_start_ns": event_start, "event_gap_end_ns": event_end,
|
||||
"sample_before_begin_ns": first[0], "sample_before_end_ns": first[1],
|
||||
"sample_after_begin_ns": last[0], "sample_after_end_ns": last[1],
|
||||
"runtime_delta_ns": last[2][0] - first[2][0],
|
||||
"runqueue_delta_ns": last[2][1] - first[2][1],
|
||||
"timeslices_delta": last[2][2] - first[2][2],
|
||||
"max_sample_gap_ns": max_gap, "sample_count": len(window)}
|
||||
|
||||
|
||||
class Soundd:
|
||||
def __init__(self, params=None, pack_root=PACK_ROOT):
|
||||
self.volume_params = params if params is not None else Params()
|
||||
@@ -196,12 +104,7 @@ class Soundd:
|
||||
self.output_underflow_count = 0
|
||||
self.reported_output_underflows = 0
|
||||
self.stream_report_at = None
|
||||
self.last_callback_start_ns = None
|
||||
self.last_callback_work_ns = None
|
||||
self.last_native_audio_time = None
|
||||
self.callback_tid = None
|
||||
self.callback_scheduler_history = _CallbackSchedulerHistory()
|
||||
self.underflow_timing = None
|
||||
self.native_audio = None
|
||||
self.axis_alerts = AxisAlerts()
|
||||
|
||||
self.spl_filter_weighted = FirstOrderFilter(0, 2.5, FILTER_DT, initialized=False)
|
||||
@@ -256,33 +159,11 @@ class Soundd:
|
||||
self.saved_volumes = {key: read_volume(self.volume_params, key).value for key, _, _ in VOLUMES}
|
||||
|
||||
def callback(self, data_out: np.ndarray, frames: int, time, status) -> None:
|
||||
started_ns = monotonic_ns()
|
||||
previous_ns = getattr(self, "last_callback_start_ns", None)
|
||||
previous_work_ns = getattr(self, "last_callback_work_ns", None)
|
||||
previous_native_time = getattr(self, "last_native_audio_time", None)
|
||||
native_time = _native_audio_time(time, "currentTime")
|
||||
dac_time = _native_audio_time(time, "outputBufferDacTime")
|
||||
native_gap = (native_time - previous_native_time if native_time is not None and
|
||||
previous_native_time is not None and native_time >= previous_native_time else None)
|
||||
if getattr(self, "callback_tid", None) is None:
|
||||
self.callback_tid = get_native_id()
|
||||
self.last_callback_start_ns = started_ns
|
||||
self.last_native_audio_time = native_time
|
||||
try:
|
||||
data_out[:frames, 0] = self.get_sound_data(frames)
|
||||
finally:
|
||||
finished_ns = monotonic_ns()
|
||||
self.last_callback_work_ns = finished_ns - started_ns
|
||||
if status:
|
||||
underflow = bool(getattr(status, "output_underflow", False))
|
||||
if underflow:
|
||||
self.underflow_timing = (self.output_underflow_count + 1, started_ns,
|
||||
None if previous_ns is None else started_ns - previous_ns,
|
||||
finished_ns - started_ns, frames, previous_work_ns,
|
||||
native_time, dac_time, native_gap, self.callback_tid)
|
||||
self.pending_stream_status = status
|
||||
self.stream_status_count += 1
|
||||
self.output_underflow_count += int(underflow)
|
||||
data_out[:frames, 0] = self.get_sound_data(frames)
|
||||
if status:
|
||||
self.pending_stream_status = status
|
||||
self.stream_status_count += 1
|
||||
self.output_underflow_count += int(bool(getattr(status, "output_underflow", False)))
|
||||
|
||||
def log_pending_stream_status(self, stream=None) -> None:
|
||||
status = self.pending_stream_status
|
||||
@@ -296,27 +177,6 @@ class Soundd:
|
||||
underflows = self.output_underflow_count
|
||||
message = (f"soundd stream diagnostics: status={status} status_callbacks={self.stream_status_count} "
|
||||
+ f"output_underflows={underflows}")
|
||||
timing = getattr(self, "underflow_timing", None)
|
||||
if timing is not None and timing[0] == underflows:
|
||||
_, started_ns, gap_ns, work_ns, frames, previous_work_ns, native_time, dac_time, native_gap, tid = timing
|
||||
gap_ms = "unknown" if gap_ns is None else f"{gap_ns / 1e6:.3f}"
|
||||
message += (f" underflow_callback_mono={started_ns / 1e9:.9f} callback_gap_ms={gap_ms}"
|
||||
+ f" callback_work_ms={work_ns / 1e6:.3f}"
|
||||
+ f" callback_period_ms={frames / SAMPLE_RATE * 1e3:.3f}")
|
||||
previous_work_ms = "unknown" if previous_work_ns is None else f"{previous_work_ns / 1e6:.3f}"
|
||||
native_text = "unknown" if native_time is None else f"{native_time:.9f}"
|
||||
dac_text = "unknown" if dac_time is None else f"{dac_time:.9f}"
|
||||
native_gap_ms = "unknown" if native_gap is None else f"{native_gap * 1e3:.3f}"
|
||||
message += (f" previous_callback_work_ms={previous_work_ms} native_current_time={native_text}"
|
||||
+ f" native_output_dac_time={dac_text} native_callback_gap_ms={native_gap_ms} callback_tid={tid}")
|
||||
if underflows > self.reported_output_underflows:
|
||||
snapshot = json.dumps(_callback_thread_snapshot(tid), separators=(",", ":"), sort_keys=True)
|
||||
message += f" callback_thread_snapshot_current={snapshot} snapshot_mono={now:.9f}"
|
||||
history = getattr(self, "callback_scheduler_history", None)
|
||||
bracket = history.bracket(tid, started_ns, gap_ns) if history is not None else {"available": False}
|
||||
message += " callback_scheduler_bracket=" + json.dumps(bracket, separators=(",", ":"), sort_keys=True)
|
||||
if stream is not None:
|
||||
message += f" latency={stream.latency} cpu_load={stream.cpu_load}"
|
||||
# Output dropouts must reach errorLogMessage, which is retained in qlog.
|
||||
if underflows > self.reported_output_underflows:
|
||||
cloudlog.error(message)
|
||||
@@ -381,6 +241,23 @@ class Soundd:
|
||||
# reload sounddevice to reinitialize portaudio
|
||||
sd._terminate()
|
||||
sd._initialize()
|
||||
if not PC:
|
||||
from openpilot.starpilot.audio.native_playback import NativePlayback
|
||||
try:
|
||||
playback = NativePlayback(self, sd)
|
||||
except (OSError, AttributeError, ValueError, MemoryError):
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
stream = playback.stream()
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
playback.close()
|
||||
except BaseException:
|
||||
playback.close()
|
||||
raise
|
||||
else:
|
||||
self.native_audio = playback
|
||||
return stream
|
||||
return sd.OutputStream(channels=1, samplerate=SAMPLE_RATE, callback=self.callback, blocksize=SAMPLE_BUFFER)
|
||||
|
||||
def soundd_thread(self):
|
||||
@@ -396,7 +273,8 @@ class Soundd:
|
||||
cloudlog.info(f"soundd stream started: {stream.samplerate=} {stream.channels=} {stream.dtype=} {stream.device=}, {stream.blocksize=}, {stream.latency=}")
|
||||
while True:
|
||||
sm.update(0)
|
||||
self.callback_scheduler_history.sample(self.callback_tid)
|
||||
if self.native_audio is not None:
|
||||
self.native_audio.sync_owner()
|
||||
self.log_pending_stream_status(stream)
|
||||
self.refresh_saved_volumes(time.monotonic())
|
||||
self.load_sounds()
|
||||
@@ -410,6 +288,8 @@ class Soundd:
|
||||
self.get_audible_alert(sm)
|
||||
|
||||
self.update_critical_sound(time.monotonic())
|
||||
if self.native_audio is not None:
|
||||
self.native_audio.publish()
|
||||
|
||||
rk.keep_time()
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import threading
|
||||
from unittest.mock import mock_open
|
||||
import numpy as np
|
||||
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
@@ -12,8 +11,6 @@ AudibleAlert = log.SelfdriveState.AudibleAlert
|
||||
|
||||
class TestSoundd(OpenpilotTestCase):
|
||||
def test_callback_defers_and_bounds_stream_status_logging(self, mocker):
|
||||
scheduler_read = mocker.patch("openpilot.selfdrive.ui.soundd._read_callback_schedstat", side_effect=AssertionError("callback I/O"))
|
||||
thread_snapshot = mocker.patch("openpilot.selfdrive.ui.soundd._callback_thread_snapshot", side_effect=AssertionError("callback I/O"))
|
||||
soundd = Soundd.__new__(Soundd)
|
||||
soundd.current_alert = AudibleAlert.none
|
||||
soundd.current_volume = 0.1
|
||||
@@ -24,8 +21,6 @@ class TestSoundd(OpenpilotTestCase):
|
||||
soundd.output_underflow_count = 0
|
||||
soundd.reported_output_underflows = 0
|
||||
soundd.stream_report_at = None
|
||||
soundd.last_callback_start_ns = None
|
||||
soundd.underflow_timing = None
|
||||
warning = mocker.patch("openpilot.selfdrive.ui.soundd.cloudlog.warning")
|
||||
error = mocker.patch("openpilot.selfdrive.ui.soundd.cloudlog.error")
|
||||
output = np.empty((8, 1), dtype=np.float32)
|
||||
@@ -35,8 +30,6 @@ class TestSoundd(OpenpilotTestCase):
|
||||
assert np.array_equal(output, np.zeros_like(output))
|
||||
warning.assert_not_called()
|
||||
error.assert_not_called()
|
||||
scheduler_read.assert_not_called()
|
||||
thread_snapshot.assert_not_called()
|
||||
|
||||
soundd.log_pending_stream_status()
|
||||
warning.assert_called_once_with("soundd stream diagnostics: status=latest underflow status_callbacks=2 output_underflows=0")
|
||||
@@ -65,106 +58,3 @@ class TestSoundd(OpenpilotTestCase):
|
||||
assert not check_selfdrive_timeout_alert(sm)
|
||||
|
||||
# TODO: add test with micd for checking that soundd actually outputs sounds
|
||||
|
||||
|
||||
def scheduler_sample(history, mocker, begin, counters, *, tid=7, duration=1):
|
||||
mocker.patch("openpilot.selfdrive.ui.soundd.monotonic_ns", side_effect=(begin, begin + duration))
|
||||
mocker.patch("openpilot.selfdrive.ui.soundd._read_callback_schedstat", return_value=counters)
|
||||
history.sample(tid)
|
||||
|
||||
|
||||
class TestSounddSchedulerHistory(OpenpilotTestCase):
|
||||
def test_scheduler_history_reports_only_explicit_bracket(self, mocker):
|
||||
from openpilot.selfdrive.ui.soundd import _CallbackSchedulerHistory
|
||||
history = _CallbackSchedulerHistory()
|
||||
for tick in range(8):
|
||||
scheduler_sample(history, mocker, tick * 50_000_000, (tick * 1_000_000, tick * 3_000_000, tick))
|
||||
bracket = history.bracket(7, 320_000_000, 270_000_000)
|
||||
assert bracket == {"available": True, "tid": 7,
|
||||
"event_gap_start_ns": 50_000_000, "event_gap_end_ns": 320_000_000,
|
||||
"sample_before_begin_ns": 0, "sample_before_end_ns": 1,
|
||||
"sample_after_begin_ns": 350_000_000, "sample_after_end_ns": 350_000_001,
|
||||
"runtime_delta_ns": 7_000_000, "runqueue_delta_ns": 21_000_000,
|
||||
"timeslices_delta": 7, "max_sample_gap_ns": 50_000_001, "sample_count": 8}
|
||||
assert history.bracket(8, 320_000_000, 270_000_000) == {"available": False}
|
||||
assert history.bracket(7, 320_000_000, None) == {"available": False}
|
||||
assert history.bracket(7, 320_000_000, 0) == {"available": False}
|
||||
assert history.bracket(7, 400_000_000, 270_000_000) == {"available": False}
|
||||
assert history.bracket(7, 320_000_000, 400_000_000) == {"available": False}
|
||||
|
||||
def test_scheduler_history_never_bridges_missing_or_reset_samples(self, mocker):
|
||||
for failure in ("gap", "slow_read", "read_error", "counter_reset", "tid_reset"):
|
||||
with self.subTest(failure=failure):
|
||||
from openpilot.selfdrive.ui.soundd import _CallbackSchedulerHistory
|
||||
history = _CallbackSchedulerHistory()
|
||||
scheduler_sample(history, mocker, 0, (100, 100, 1))
|
||||
if failure == "gap":
|
||||
scheduler_sample(history, mocker, 200_000_000, (200, 200, 2))
|
||||
elif failure == "slow_read":
|
||||
scheduler_sample(history, mocker, 50_000_000, (200, 200, 2), duration=101_000_000)
|
||||
elif failure == "read_error":
|
||||
mocker.patch("openpilot.selfdrive.ui.soundd.monotonic_ns", return_value=50_000_000)
|
||||
mocker.patch("openpilot.selfdrive.ui.soundd._read_callback_schedstat", side_effect=OSError("missing proc"))
|
||||
history.sample(7)
|
||||
elif failure == "counter_reset":
|
||||
scheduler_sample(history, mocker, 50_000_000, (1, 1, 1))
|
||||
else:
|
||||
scheduler_sample(history, mocker, 50_000_000, (200, 200, 2), tid=8)
|
||||
scheduler_sample(history, mocker, 250_000_000, (300, 300, 3), tid=8 if failure == "tid_reset" else 7)
|
||||
assert history.bracket(7, 220_000_000, 210_000_000) == {"available": False}
|
||||
|
||||
def test_scheduler_history_is_capped_and_has_no_regular_logging(self, mocker):
|
||||
from openpilot.selfdrive.ui.soundd import _CallbackSchedulerHistory
|
||||
warning = mocker.patch("openpilot.selfdrive.ui.soundd.cloudlog.warning")
|
||||
error = mocker.patch("openpilot.selfdrive.ui.soundd.cloudlog.error")
|
||||
history = _CallbackSchedulerHistory()
|
||||
for tick in range(140):
|
||||
scheduler_sample(history, mocker, tick * 50_000_000, (tick, tick, tick))
|
||||
assert len(history.samples) == 128
|
||||
warning.assert_not_called()
|
||||
error.assert_not_called()
|
||||
history.sample(None)
|
||||
assert not history.samples
|
||||
|
||||
def test_scheduler_reader_rejects_malformed_counters(self, mocker):
|
||||
for raw in ("1 2", "1 2 3 4", "-1 2 3", "1 bad 3"):
|
||||
with self.subTest(raw=raw):
|
||||
from openpilot.selfdrive.ui.soundd import _read_callback_schedstat
|
||||
opened = mocker.patch("pathlib.Path.open", mock_open(read_data=raw))
|
||||
with self.assertRaises(ValueError):
|
||||
_read_callback_schedstat(7)
|
||||
opened().read.assert_called_once_with(128)
|
||||
|
||||
def test_underflow_log_contains_scheduler_bracket_without_callback_io(self, mocker):
|
||||
from types import SimpleNamespace
|
||||
from openpilot.selfdrive.ui.soundd import _CallbackSchedulerHistory
|
||||
owner = Soundd.__new__(Soundd)
|
||||
owner.current_alert, owner.current_volume = AudibleAlert.none, 0.1
|
||||
owner.loaded_sounds, owner.saved_volumes = {}, {}
|
||||
owner.pending_stream_status = None
|
||||
owner.stream_status_count = owner.output_underflow_count = owner.reported_output_underflows = 0
|
||||
owner.stream_report_at = owner.underflow_timing = None
|
||||
owner.last_callback_start_ns = 25_000_000
|
||||
owner.callback_tid = 7
|
||||
owner.callback_scheduler_history = _CallbackSchedulerHistory()
|
||||
for tick in range(4):
|
||||
scheduler_sample(owner.callback_scheduler_history, mocker, tick * 50_000_000, (tick, tick * 10, tick))
|
||||
reader = mocker.patch("openpilot.selfdrive.ui.soundd._read_callback_schedstat", side_effect=AssertionError("callback I/O"))
|
||||
snapshot = mocker.patch("openpilot.selfdrive.ui.soundd._callback_thread_snapshot", return_value={})
|
||||
error = mocker.patch("openpilot.selfdrive.ui.soundd.cloudlog.error")
|
||||
mocker.patch("openpilot.selfdrive.ui.soundd.monotonic_ns", side_effect=(125_000_000, 125_001_000))
|
||||
mocker.patch("openpilot.selfdrive.ui.soundd.time.monotonic", return_value=0.2)
|
||||
output = np.empty((8, 1), dtype=np.float32)
|
||||
owner.callback(output, 8, None, SimpleNamespace(output_underflow=True))
|
||||
assert np.array_equal(output, np.zeros_like(output))
|
||||
reader.assert_not_called()
|
||||
snapshot.assert_not_called()
|
||||
error.assert_not_called()
|
||||
owner.log_pending_stream_status()
|
||||
error.assert_called_once()
|
||||
message = error.call_args.args[0]
|
||||
assert 'callback_scheduler_bracket={"available":true,' in message
|
||||
assert '"runqueue_delta_ns":30' in message
|
||||
assert '"event_gap_start_ns":25000000' in message
|
||||
owner.log_pending_stream_status()
|
||||
error.assert_called_once()
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
Import('env', 'arch')
|
||||
|
||||
if arch == "comma_arm64":
|
||||
native_audio = env.Clone()
|
||||
native_audio.Append(CFLAGS=['-std=c11'])
|
||||
native_audio.SharedLibrary('audio_mixer', ['audio_mixer.c'])
|
||||
@@ -0,0 +1,115 @@
|
||||
// Bounded mono float32 alert renderer. PCM allocation/publication/reclamation occur on the control thread only.
|
||||
#define _GNU_SOURCE
|
||||
#include <stdatomic.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
|
||||
#define SLOTS 16
|
||||
#define MAX_PCM 1440000U
|
||||
#define MAX_FRAMES 4096U
|
||||
_Static_assert(ATOMIC_LONG_LOCK_FREE == 2 && ATOMIC_LLONG_LOCK_FREE == 2 && ATOMIC_POINTER_LOCK_FREE == 2, "Audio requires lock-free atomics");
|
||||
struct pa_time { double input_adc, current, output_dac; };
|
||||
struct bank { uint64_t revision; unsigned lengths[SLOTS]; float *pcm[SLOTS]; };
|
||||
struct audio {
|
||||
struct bank *active;
|
||||
_Atomic(struct bank *) pending, retired;
|
||||
_Atomic uint64_t command_seq, command[8], status_seq, status[10];
|
||||
uint64_t applied_seq, restart, frame, stop_frame;
|
||||
uint64_t status_count, underflows, last_flags;
|
||||
unsigned alert, sound;
|
||||
int loops;
|
||||
float gain;
|
||||
bool pending_stop;
|
||||
atomic_flag rendering;
|
||||
};
|
||||
static void free_bank(struct bank *b) { if (b) { for(unsigned i=0;i<SLOTS;i++) free(b->pcm[i]); free(b); } }
|
||||
unsigned audio_mixer_abi(void) { return 3; }
|
||||
struct audio *audio_create(void) { struct audio *a=calloc(1,sizeof(*a)); if(a) { *a=(struct audio){.rendering=ATOMIC_FLAG_INIT,.loops=-1}; } return a; }
|
||||
void audio_collect(struct audio *a) { free_bank(atomic_exchange(&a->retired,NULL)); }
|
||||
int audio_can_publish(struct audio *a) { return a && atomic_load(&a->pending)==NULL; }
|
||||
int audio_publish_bank(struct audio *a,const float *const *pcm,const unsigned *lengths,unsigned count,uint64_t revision) {
|
||||
if(!a || !revision || count>SLOTS || atomic_load(&a->pending)) return 0;
|
||||
audio_collect(a);
|
||||
struct bank *b=calloc(1,sizeof(*b)); if(!b) return 0; b->revision=revision;
|
||||
for(unsigned i=0;i<count;i++) {
|
||||
if(!lengths[i] || lengths[i]>MAX_PCM || !pcm[i]) { free_bank(b); return 0; }
|
||||
b->pcm[i]=malloc((size_t)lengths[i]*sizeof(float)); if(!b->pcm[i]) {free_bank(b);return 0;}
|
||||
memcpy(b->pcm[i],pcm[i],(size_t)lengths[i]*sizeof(float)); b->lengths[i]=lengths[i];
|
||||
}
|
||||
atomic_store(&a->pending,b); return 1;
|
||||
}
|
||||
uint64_t audio_command(struct audio *a,unsigned alert,unsigned sound,int loops,int stop,float gain,uint64_t restart,uint64_t bank_revision,uint64_t stop_frame) {
|
||||
if(!a || sound>=SLOTS || loops < -1 || loops==0 || !isfinite(gain) || gain<0 || gain>1) return 0;
|
||||
uint32_t bits;memcpy(&bits,&gain,sizeof(bits));
|
||||
uint64_t seq=atomic_load(&a->command_seq);atomic_store(&a->command_seq,seq+1);
|
||||
uint64_t values[8]={alert,sound,(uint64_t)(int64_t)loops,stop!=0,bits,restart,bank_revision,stop_frame};
|
||||
for(unsigned i=0;i<8;i++) atomic_store(&a->command[i],values[i]);
|
||||
atomic_store(&a->command_seq,seq+2); return seq+2;
|
||||
}
|
||||
static void accept_command(struct audio *a) {
|
||||
uint64_t seq=atomic_load(&a->command_seq),v[8];
|
||||
if(seq==a->applied_seq || (seq&1)) return;
|
||||
for(unsigned i=0;i<8;i++) v[i]=atomic_load(&a->command[i]);
|
||||
if(seq!=atomic_load(&a->command_seq)) return;
|
||||
if(!a->active || v[6]!=a->active->revision) {
|
||||
struct bank *next=atomic_load(&a->pending);
|
||||
if(!next || next->revision!=v[6] || atomic_load(&a->retired)) return;
|
||||
next=atomic_exchange(&a->pending,NULL);
|
||||
struct bank *old=a->active;a->active=next;atomic_store(&a->retired,old);
|
||||
}
|
||||
if(v[5]!=a->restart) { a->frame=0;a->restart=v[5]; }
|
||||
a->alert=(unsigned)v[0];a->sound=(unsigned)v[1];a->loops=(int)(int64_t)v[2];
|
||||
a->pending_stop=v[3]!=0;a->stop_frame=v[7];
|
||||
// The main thread may publish dismissal after this cursor has already crossed
|
||||
// its observed loop endpoint. Never replay an additional loop in that case.
|
||||
if(a->pending_stop && a->frame>=a->stop_frame) { a->alert=0;a->pending_stop=false; }
|
||||
uint32_t bits=(uint32_t)v[4];memcpy(&a->gain,&bits,sizeof(bits));a->applied_seq=seq;
|
||||
}
|
||||
// Initialization only: caller has not yet opened/started a PortAudio stream.
|
||||
// Admit the initial bank without rendering samples or publishing fake timing.
|
||||
int audio_prepare(struct audio *a) {
|
||||
if(!a) return 0;
|
||||
accept_command(a);
|
||||
return a->active!=NULL && a->applied_seq!=0;
|
||||
}
|
||||
static int audio_callback(const void *input,void *output,unsigned long frames,const struct pa_time *t,unsigned long flags,void *user) {
|
||||
(void)input;(void)t;struct audio *a=user;
|
||||
if(!a || !output || frames>MAX_FRAMES || atomic_flag_test_and_set(&a->rendering)) return 2;
|
||||
accept_command(a);
|
||||
float *out=output;memset(out,0,frames*sizeof(float));
|
||||
unsigned length=a->active?a->active->lengths[a->sound]:0;
|
||||
if(a->alert && !length) { atomic_flag_clear(&a->rendering);return 2; }
|
||||
unsigned written=0;
|
||||
while(a->alert && written<frames && (a->loops<0 || a->frame/length<(uint64_t)a->loops)) {
|
||||
unsigned pos=(unsigned)(a->frame%length),n=length-pos;
|
||||
if(n>frames-written)n=(unsigned)frames-written;
|
||||
memcpy(out+written,a->active->pcm[a->sound]+pos,n*sizeof(float));written+=n;a->frame+=n;
|
||||
if(a->pending_stop && a->frame%length==0) { a->alert=0;a->pending_stop=false;break; }
|
||||
}
|
||||
for(unsigned i=0;i<frames;i++) out[i]*=a->gain;
|
||||
// Only detached stream health and playback cursor cross the callback boundary.
|
||||
uint64_t status_seq=atomic_load(&a->status_seq);atomic_store(&a->status_seq,status_seq+1);
|
||||
if(flags) { a->status_count++;a->last_flags=flags; }
|
||||
if(flags&4U) a->underflows++;
|
||||
atomic_store(&a->status[0],a->frame);atomic_store(&a->status[1],a->alert);atomic_store(&a->status[2],a->pending_stop);
|
||||
atomic_store(&a->status[3],a->status_count);atomic_store(&a->status[4],a->underflows);atomic_store(&a->status[5],a->last_flags);
|
||||
atomic_store(&a->status[6],1U);atomic_store(&a->status[7],a->applied_seq);
|
||||
atomic_store(&a->status[8],a->restart);atomic_store(&a->status[9],a->sound);
|
||||
atomic_store(&a->status_seq,status_seq+2);
|
||||
atomic_flag_clear(&a->rendering);return 0;
|
||||
}
|
||||
uintptr_t audio_callback_address(void) { return (uintptr_t)&audio_callback; }
|
||||
int audio_status(struct audio *a,uint64_t *out) {
|
||||
if(!a || !out) return 0;
|
||||
for(unsigned attempt=0;attempt<3;attempt++) {
|
||||
uint64_t seq=atomic_load(&a->status_seq);if(seq&1)continue;
|
||||
for(unsigned i=0;i<10;i++)out[i]=atomic_load(&a->status[i]);
|
||||
if(seq==atomic_load(&a->status_seq))return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
// Caller MUST stop/close PortAudio before destroying this stream-owned mixer.
|
||||
void audio_destroy(struct audio *a) { if(a) {free_bank(a->active);free_bank(atomic_load(&a->pending));free_bank(atomic_load(&a->retired));free(a);} }
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,216 @@
|
||||
"""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'
|
||||
# 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
|
||||
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 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()
|
||||
@@ -14,11 +14,6 @@ def make_daemon():
|
||||
daemon.output_underflow_count = 0
|
||||
daemon.reported_output_underflows = 0
|
||||
daemon.stream_report_at = None
|
||||
daemon.last_callback_start_ns = None
|
||||
daemon.last_callback_work_ns = None
|
||||
daemon.last_native_audio_time = None
|
||||
daemon.callback_tid = 42
|
||||
daemon.underflow_timing = None
|
||||
samples = np.array([0.5, -0.5, 0., 0.25], dtype=np.float32)
|
||||
daemon.get_sound_data = Mock(side_effect=lambda frames: samples[:frames])
|
||||
return daemon, samples
|
||||
@@ -29,12 +24,8 @@ def test_underflow_counters_preserve_signal_and_log_on_service_thread():
|
||||
output = np.empty((4, 1), dtype=np.float32)
|
||||
underflow = SimpleNamespace(output_underflow=True)
|
||||
other = SimpleNamespace(output_underflow=False)
|
||||
clocks = [1_000_000_000, 1_001_000_000, 1_085_333_333, 1_087_333_333,
|
||||
1_170_666_666, 1_171_666_666, 1_256_000_000, 1_257_000_000]
|
||||
with patch.object(soundd.cloudlog, "warning") as warning, patch.object(soundd.cloudlog, "error") as error, \
|
||||
patch.object(soundd, "monotonic_ns", side_effect=clocks), \
|
||||
patch.object(soundd.time, "monotonic", return_value=2.), \
|
||||
patch.object(soundd, "_callback_thread_snapshot", return_value={"tid": 42}) as snapshot:
|
||||
patch.object(soundd.time, "monotonic", return_value=2.):
|
||||
for status in (underflow, underflow, other, None):
|
||||
daemon.callback(output, 4, None, status)
|
||||
np.testing.assert_array_equal(output[:, 0], samples)
|
||||
@@ -42,17 +33,10 @@ def test_underflow_counters_preserve_signal_and_log_on_service_thread():
|
||||
error.assert_not_called()
|
||||
assert daemon.stream_status_count == 3
|
||||
assert daemon.output_underflow_count == 2
|
||||
snapshot.assert_not_called()
|
||||
daemon.log_pending_stream_status(SimpleNamespace(latency=0.2, cpu_load=0.01))
|
||||
warning.assert_not_called()
|
||||
error.assert_called_once_with(
|
||||
f"soundd stream diagnostics: status={other} status_callbacks=3 output_underflows=2"
|
||||
+ " underflow_callback_mono=1.085333333 callback_gap_ms=85.333 callback_work_ms=2.000"
|
||||
+ " callback_period_ms=0.083 previous_callback_work_ms=1.000 native_current_time=unknown"
|
||||
+ " native_output_dac_time=unknown native_callback_gap_ms=unknown callback_tid=42"
|
||||
+ ' callback_thread_snapshot_current={"tid":42} snapshot_mono=2.000000000'
|
||||
+ ' callback_scheduler_bracket={"available":false} latency=0.2 cpu_load=0.01')
|
||||
snapshot.assert_called_once_with(42)
|
||||
f"soundd stream diagnostics: status={other} status_callbacks=3 output_underflows=2")
|
||||
daemon.log_pending_stream_status(SimpleNamespace(latency=0.2, cpu_load=0.01))
|
||||
assert error.call_count == 1
|
||||
|
||||
@@ -96,77 +80,56 @@ def test_other_status_stays_a_warning_and_underflow_counter_survives_status_clea
|
||||
assert "output_underflows=1" in error.call_args.args[0]
|
||||
|
||||
|
||||
def test_late_callback_and_slow_work_are_distinct_without_changing_samples():
|
||||
|
||||
def test_status_callbacks_preserve_samples_and_defer_health_reports():
|
||||
daemon, short_samples = make_daemon()
|
||||
samples = np.tile(short_samples, 1024)
|
||||
daemon.get_sound_data = Mock(side_effect=lambda frames: samples[:frames])
|
||||
output = np.empty((4096, 1), dtype=np.float32)
|
||||
underflow = SimpleNamespace(output_underflow=True)
|
||||
clocks = [1_000_000_000, 1_001_000_000, 1_200_000_000, 1_201_000_000,
|
||||
1_285_333_333, 1_385_333_333, 1_500_000_000, 1_501_000_000]
|
||||
with patch.object(soundd, "monotonic_ns", side_effect=clocks), \
|
||||
patch.object(soundd.time, "monotonic", side_effect=[0., 5.]), \
|
||||
with patch.object(soundd.time, "monotonic", side_effect=[0., 5.]), \
|
||||
patch("pathlib.Path.open", side_effect=AssertionError("callback file IO")), \
|
||||
patch.object(soundd, "_callback_thread_snapshot", return_value={"tid": 42}) as snapshot, \
|
||||
patch.object(soundd.cloudlog, "error") as error, patch.object(soundd.cloudlog, "warning") as warning:
|
||||
for status, native in ((None, 10.), (underflow, 10.085333333)):
|
||||
daemon.callback(output, 4096, SimpleNamespace(currentTime=native, outputBufferDacTime=native + .085333333), status)
|
||||
for status in (None, underflow):
|
||||
daemon.callback(output, 4096, None, status)
|
||||
assert output[:, 0].tobytes() == samples.tobytes()
|
||||
error.assert_not_called()
|
||||
warning.assert_not_called()
|
||||
snapshot.assert_not_called()
|
||||
daemon.log_pending_stream_status()
|
||||
assert "native_callback_gap_ms=85.333" in error.call_args.args[0]
|
||||
assert "previous_callback_work_ms=1.000" in error.call_args.args[0]
|
||||
assert "callback_gap_ms=200.000 callback_work_ms=1.000 callback_period_ms=85.333" in error.call_args.args[0]
|
||||
for status, native in ((underflow, 10.170666666), (None, 10.256)):
|
||||
daemon.callback(output, 4096, SimpleNamespace(currentTime=native, outputBufferDacTime=native + .085333333), status)
|
||||
assert "output_underflows=1" in error.call_args.args[0]
|
||||
for status in (underflow, None):
|
||||
daemon.callback(output, 4096, None, status)
|
||||
assert output[:, 0].tobytes() == samples.tobytes()
|
||||
assert error.call_count == 1
|
||||
warning.assert_not_called()
|
||||
daemon.log_pending_stream_status()
|
||||
assert error.call_count == 2
|
||||
message = error.call_args.args[0]
|
||||
assert "output_underflows=2 underflow_callback_mono=1.285333333" in message
|
||||
assert "callback_gap_ms=85.333 callback_work_ms=100.000 callback_period_ms=85.333" in message
|
||||
assert "status_callbacks=2 output_underflows=2" in error.call_args.args[0]
|
||||
|
||||
|
||||
def test_native_clock_discontinuity_does_not_change_output_or_reuse_old_time():
|
||||
def test_native_clock_metadata_does_not_change_output_or_health_counts():
|
||||
daemon, samples = make_daemon()
|
||||
output = np.empty((4, 1), dtype=np.float32)
|
||||
underflow = SimpleNamespace(output_underflow=True)
|
||||
with patch.object(soundd, "_callback_thread_snapshot", return_value={"tid": 42}), \
|
||||
patch.object(soundd.time, "monotonic", side_effect=[0., 5., 10., 15., 20.]), patch.object(soundd.cloudlog, "error") as error:
|
||||
for native in (10., float("nan"), 9., 8., -1.):
|
||||
with patch.object(soundd.time, "monotonic", side_effect=[0., 5., 10., 15., 20.]), \
|
||||
patch.object(soundd.cloudlog, "error") as error:
|
||||
for count, native in enumerate((10., float("nan"), 9., 8., -1.), 1):
|
||||
daemon.callback(output, 4, SimpleNamespace(currentTime=native, outputBufferDacTime=float("inf")), underflow)
|
||||
assert output[:, 0].tobytes() == samples.tobytes()
|
||||
assert daemon.output_underflow_count == count
|
||||
daemon.log_pending_stream_status()
|
||||
assert "native_callback_gap_ms=unknown" in error.call_args.args[0]
|
||||
assert "native_output_dac_time=unknown" in error.call_args.args[0]
|
||||
if native == 9.:
|
||||
assert "native_current_time=9.000000000" in error.call_args.args[0]
|
||||
elif native == 8.:
|
||||
assert "native_current_time=8.000000000" in error.call_args.args[0]
|
||||
assert "native_current_time=-1.000000000" in error.call_args.args[0]
|
||||
assert f"output_underflows={count}" in error.call_args.args[0]
|
||||
assert error.call_count == 5
|
||||
|
||||
|
||||
def test_thread_snapshot_is_post_event_and_proc_failure_keeps_underflow_report(tmp_path):
|
||||
fields = ["S"] + ["0"] * 36
|
||||
fields[15], fields[16], fields[36] = "20", "0", "3"
|
||||
(tmp_path / "stat").write_text("42 (callback thread) " + " ".join(fields))
|
||||
(tmp_path / "schedstat").write_text("100 200 3")
|
||||
with patch.object(soundd, "Path", return_value=tmp_path), \
|
||||
patch.object(soundd.os, "sched_getscheduler", return_value=0, create=True), \
|
||||
patch.object(soundd.os, "sched_getaffinity", return_value={1, 3}, create=True):
|
||||
result = soundd._callback_thread_snapshot(42)
|
||||
assert result == {"tid": 42, "state": "S", "priority": 20, "nice": 0, "processor": 3,
|
||||
"runtime_ns": 100, "runqueue_ns": 200, "timeslices": 3, "policy": 0, "affinity": [1, 3]}
|
||||
def test_proc_failure_does_not_affect_underflow_report_or_samples():
|
||||
daemon, samples = make_daemon()
|
||||
output = np.empty((4, 1), dtype=np.float32)
|
||||
with patch("pathlib.Path.open", side_effect=FileNotFoundError), patch.object(soundd.cloudlog, "error") as error:
|
||||
with patch("pathlib.Path.open", side_effect=FileNotFoundError) as file_open, \
|
||||
patch.object(soundd.cloudlog, "error") as error:
|
||||
daemon.callback(output, 4, None, SimpleNamespace(output_underflow=True))
|
||||
assert output[:, 0].tobytes() == samples.tobytes()
|
||||
error.assert_not_called()
|
||||
daemon.log_pending_stream_status()
|
||||
file_open.assert_not_called()
|
||||
assert "output_underflows=1" in error.call_args.args[0]
|
||||
assert 'callback_thread_snapshot_current={"tid":42,"unavailable":true}' in error.call_args.args[0]
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"protected_inventory_sha256": "a93ce0e776ab0abe390ade88f83bed71fd98d9c71c2976bb29d7e660d44347e5", "schema_version": 1}
|
||||
{"protected_inventory_sha256": "3f389147d8db51d535b549a991bd0ece6acd750d3d407a6884b075767a7f60d4", "schema_version": 1}
|
||||
|
||||
Reference in New Issue
Block a user