Skip to content

Commit ee0efd8

Browse files
committed
A probe that opens a real sound device, because every desktop claim so far has been a digest of a file
PyDevices/audioif#7. `identity.py` proves the pump's bytes are the old path's bytes and `lifecycle.py` proves the contract holds, and both record to a WAV through `emulated_audio`. Neither has ever opened a sound device. So "the pump works on the desktop" and "the pump reaches a speaker on the desktop" were two statements and only the first had evidence. `tests/pump_probes/device.py` runs the same graph twice -- once into the recording transport, once into whatever `audiodev.auto` picks on this host -- with a tee in front of the real transport that digests every byte the device layer ACCEPTS (not what we offered: a short write is the failure this is closest to, and it is silent). Two graphs: a RawSample, which arrives in one write and says the wire is connected, and a WaveFile, which goes through the prefetcher in 188 small writes and says the ring drains correctly. Both desktop ports, today: unix sdl2_audio, SDL device id 2 raw 96 000 B digest e0c42e66584d6178 SAME wav 192 000 B digest aac53cd39e17dbdd SAME, 188 writes, 0 short windows win_audio, WASAPI IAudioClient open, 96 000-frame buffer the same two digests, both SAME and the three planted faults refused on both: `flip` caught at byte 999, `short` reported as a prefix, `nodev` refusing rather than agreeing with itself about audio that went nowhere. **It found one thing, in its own first run, and the thing it found is about this file rather than the product.** The tee did not forward `_prebuffer_bytes`, and the streaming case stopped dead after 12 writes with 12 288 bytes queued: AudioOut's backpressure capped at one lookahead while the SDL device waits for 96 000 bytes before it unpauses, each side waiting on the other. That deadlock is exactly the one `_pump_locked` documents and guards against -- so the instrument accidentally planted the fault the guard exists for, and in doing so demonstrated that the guard is load-bearing. Any wrapper transport has the same sharp edge; filed separately. The no-device skip has its own switch. A hosted CI runner has no sound card and never will, so tying it to PYDEVICES_REQUIRE_PUMP would mean either a permanently red job or quietly dropping the probe. PYDEVICES_REQUIRE_DEVICE=1 is for a bench with speakers. What this does not claim: that it sounded right. A digest cannot hear, and the probe says so in its own output.
1 parent 73e2cc6 commit ee0efd8

2 files changed

Lines changed: 398 additions & 0 deletions

File tree

tests/pump_probes/device.py

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
# SPDX-FileCopyrightText: 2026 Brad Barnett
2+
#
3+
# SPDX-License-Identifier: MIT
4+
# The pump, through a REAL output device, on whichever desktop this is.
5+
#
6+
# <interpreter> tests/pump_probes/device.py [--fault WHICH]
7+
#
8+
# Faults: flip (one byte changed on the way to the device)
9+
# short (the device layer is handed half the stream)
10+
# nodev (no transport at all -- the probe must refuse, not agree)
11+
#
12+
# WHY IT EXISTS
13+
# -------------
14+
# Every desktop claim about this pump has been a digest of a FILE
15+
# (PyDevices/audioif#7). `identity.py` beside this proves the pump's bytes are
16+
# the old path's bytes, and `lifecycle.py` proves the contract holds -- both
17+
# through `emulated_audio`, which records to a WAV. Neither has ever opened a
18+
# sound device. So "the pump works on the desktop" and "the pump reaches the
19+
# speakers on the desktop" were two statements, and only the first had
20+
# evidence.
21+
#
22+
# This runs the same graph twice: once into the recording transport, once
23+
# into the transport `audiodev.auto` picks on this host -- SDL2 on unix,
24+
# WASAPI through `audiodev.win_audio` on Windows -- with a tee in front of it
25+
# that digests every byte on its way in. If the two digests agree, then the
26+
# bytes that reached the device layer are exactly the bytes the pump made.
27+
#
28+
# WHAT IT DOES NOT CLAIM
29+
# ----------------------
30+
# That it SOUNDED right. Nobody's ears are on this bench, and a digest cannot
31+
# hear. What this establishes is the three things a digest can: a real device
32+
# opened, the pump's bytes arrived at it unchanged, and the device took them.
33+
# Whether the result is music is a listen, and the listen is Brad's.
34+
#
35+
# It also does not claim the device played every byte it accepted. A queued
36+
# transport holds PCM in its own buffer, and at the end of a short run some of
37+
# it is still there; `queued` below says how much.
38+
39+
import os
40+
import sys
41+
import time
42+
from array import array
43+
44+
import audiocore
45+
import audiodev
46+
from audiodev import emulated_audio
47+
from audiodev import pump as pump_mod
48+
from audiodev.sample_out import AudioOut
49+
50+
RATE = 48000
51+
CHANNELS = 2
52+
FMT = audiodev.AudioFormat(RATE, CHANNELS, 16)
53+
TMP = os.getenv("PUMP_PROBE_TMP", "/tmp")
54+
55+
FNV_OFFSET = 0xCBF29CE484222325
56+
FNV_PRIME = 0x100000001B3
57+
MASK = 0xFFFFFFFFFFFFFFFF
58+
59+
60+
def digest(data):
61+
h = FNV_OFFSET
62+
for b in data:
63+
h = ((h ^ b) * FNV_PRIME) & MASK
64+
return h
65+
66+
67+
def tone(seconds=0.5, freq=220.0):
68+
"""The same deterministic sawtooth identity.py renders, so the two probes
69+
are talking about the same audio."""
70+
n = int(RATE * seconds)
71+
buf = array("h", bytes(2 * CHANNELS * n))
72+
step = int(65536 * freq / RATE)
73+
phase = 0
74+
for i in range(n):
75+
phase = (phase + step) & 0xFFFF
76+
v = phase - 32768
77+
v = (v * 7000) >> 15
78+
buf[i * CHANNELS] = v
79+
buf[i * CHANNELS + 1] = -v
80+
return buf
81+
82+
83+
class Tee:
84+
"""A PCMOutput that hands everything to a real device and keeps a copy.
85+
86+
This is the measuring instrument, and it sits at the LAST point the bytes
87+
are ours: one more call and they are inside SDL or WASAPI. Delegation is
88+
explicit rather than `__getattr__` because this runs under MicroPython and
89+
CircuitPython too, and a missing method must be an error here rather than
90+
a silent None at the device.
91+
"""
92+
93+
def __init__(self, inner):
94+
self.inner = inner
95+
self.seen = bytearray()
96+
self.calls = 0
97+
self.short_writes = 0
98+
# AudioOut's backpressure cap reads this off the transport, and a
99+
# wrapper that hides it DEADLOCKS: the cap stays at one lookahead
100+
# (11 520 B here) while the SDL device waits for 96 000 B before it
101+
# unpauses, so each side waits for the other and the stream stops
102+
# after 12 writes. Found the hard way by this probe's first run --
103+
# the instrument planted the fault, which is also the clearest
104+
# demonstration that the guard in `_pump_locked` is load-bearing.
105+
self._prebuffer_bytes = getattr(inner, "_prebuffer_bytes", 0)
106+
107+
# -- what AudioOut asks of a transport ---------------------------------
108+
@property
109+
def format(self):
110+
return self.inner.format
111+
112+
@property
113+
def codec(self):
114+
return self.inner.codec
115+
116+
@property
117+
def volume(self):
118+
return self.inner.volume
119+
120+
def set_volume(self, percent):
121+
return self.inner.set_volume(percent)
122+
123+
@property
124+
def muted(self):
125+
return self.inner.muted
126+
127+
def mute(self, value=True):
128+
return self.inner.mute(value)
129+
130+
def open(self):
131+
return self.inner.open()
132+
133+
def close(self):
134+
return self.inner.close()
135+
136+
def service(self):
137+
return self.inner.service()
138+
139+
def queued_size(self):
140+
return self.inner.queued_size()
141+
142+
def write(self, data):
143+
taken = self.inner.write(data)
144+
self.calls += 1
145+
# Record exactly what the device layer ACCEPTED, not what we offered.
146+
# A transport that takes less than it is given is the failure this
147+
# probe is closest to: the missing bytes are silent, nothing raises,
148+
# and a recording of what we tried to write would agree with itself.
149+
if taken is None:
150+
taken = len(data)
151+
if taken < len(data):
152+
self.short_writes += 1
153+
self.seen += bytes(memoryview(data)[:taken])
154+
return taken
155+
156+
157+
def device_evidence(transport):
158+
"""Something only an OPEN device can produce, per backend.
159+
160+
Not `is_active()`: a queued transport stays paused until it has a
161+
prebuffer, so "not active" is its normal state one block in. What is
162+
asked for here is the handle itself.
163+
"""
164+
name = type(transport).__name__
165+
handle = getattr(transport, "device", None)
166+
if handle:
167+
return "%s, SDL device id %s" % (name, handle)
168+
client = getattr(transport, "_client", None)
169+
if client:
170+
frames = getattr(transport, "_buffer_frames", "?")
171+
return "%s, WASAPI IAudioClient open, %s-frame buffer" % (name, frames)
172+
return None
173+
174+
175+
def graph_raw(state):
176+
"""One RawSample. Its "block" is its whole buffer, so this arrives at the
177+
device in a single write -- the simplest possible statement that the path
178+
is connected."""
179+
return audiocore.RawSample(tone(), sample_rate=RATE,
180+
channel_count=CHANNELS)
181+
182+
183+
def graph_wav(state):
184+
"""A file-backed source, which is what actually STREAMS.
185+
186+
The pump refuses a file in the graph, so a prefetcher appears in front of
187+
it and the blocks become small and many -- which is the case where the
188+
ring drains repeatedly and a lost or duplicated drain would show. A single
189+
write proves the wire; this proves the loop.
190+
"""
191+
path = TMP + "/pump_device_src.wav"
192+
pcm = bytes(memoryview(tone(1.0, 330.0)))
193+
with open(path, "wb") as fh:
194+
fh.write(emulated_audio.wav_header(FMT, len(pcm)))
195+
fh.write(pcm)
196+
fh = open(path, "rb")
197+
state.append(fh)
198+
return audiocore.WaveFile(fh)
199+
200+
201+
GRAPHS = {"raw": graph_raw, "wav": graph_wav}
202+
203+
204+
def render(case, path, transport):
205+
"""Play `case` through `transport`, returning what it received."""
206+
state = []
207+
out = AudioOut(transport, chunk_ms=20, pump=True)
208+
out.play(GRAPHS[case](state))
209+
t0 = time.time()
210+
deadline = t0 + 20.0
211+
while time.time() < deadline:
212+
out.service()
213+
if not out.playing:
214+
break
215+
elapsed = time.time() - t0
216+
queued = 0
217+
try:
218+
queued = transport.queued_size()
219+
except Exception:
220+
pass
221+
out.close()
222+
for obj in state:
223+
close = getattr(obj, "close", None)
224+
if close is not None:
225+
try:
226+
close()
227+
except Exception:
228+
pass
229+
pump_mod.forget()
230+
if path is not None:
231+
with open(path, "rb") as fh:
232+
return emulated_audio.parse_pcm_wav(fh.read())[1], elapsed, queued
233+
return None, elapsed, queued
234+
235+
236+
def main():
237+
args = sys.argv[1:]
238+
fault = None
239+
if "--fault" in args:
240+
i = args.index("--fault")
241+
fault = args[i + 1]
242+
del args[i:i + 2]
243+
cases = args or ["raw", "wav"]
244+
245+
print("the pump, to a real device")
246+
print(" pump available:", pump_mod.available(), " fault:", fault)
247+
248+
from audiodev import auto
249+
chosen = None
250+
try:
251+
chosen = auto.select_backend()
252+
except Exception as err:
253+
print(" no backend: %s" % err)
254+
print(" backend:", chosen)
255+
256+
ok = True
257+
for case in cases:
258+
# The reference: the same graph into the recording transport, which is
259+
# what identity.py has already held to the old path byte for byte.
260+
ref_path = TMP + "/pump_device_ref.wav"
261+
reference, ref_secs, _ = render(
262+
case, ref_path, emulated_audio.WavPCMOutput(ref_path, FMT))
263+
264+
if fault == "nodev":
265+
# The shape this probe exists to refuse: no device, and a
266+
# comparison that would otherwise have nothing to disagree with.
267+
tee = None
268+
else:
269+
try:
270+
tee = Tee(auto.pcm_out(FMT))
271+
except Exception as err:
272+
print(" the backend would not open a device: %s: %s"
273+
% (type(err).__name__, err))
274+
print("VERDICT: NOT PROVEN -- no device opened on this host")
275+
return 2
276+
277+
if tee is None:
278+
print(" %s: no transport was built" % case)
279+
print("VERDICT: NOT PROVEN -- nothing reached a device layer")
280+
return 1
281+
282+
# The device opens lazily: SDLPCMOutput builds with `device` 0 and
283+
# calls SDL_OpenAudioDevice on open(). Asking for evidence before that
284+
# is asking a closed device to prove it is open, which it cannot, so
285+
# open it here -- AudioOut would do it a moment later anyway.
286+
try:
287+
tee.open()
288+
except Exception as err:
289+
print(" the device would not open: %s: %s"
290+
% (type(err).__name__, err))
291+
print("VERDICT: NOT PROVEN -- no device opened on this host")
292+
return 2
293+
evidence = device_evidence(tee.inner)
294+
if evidence is None:
295+
# A transport object with no handle behind it is not a device.
296+
# Refuse rather than compare: the digests would agree perfectly
297+
# about audio that went nowhere, which is this workspace's
298+
# signature failure.
299+
print(" the transport has no open device handle behind it")
300+
print("VERDICT: NOT PROVEN -- %s produced no device evidence"
301+
% type(tee.inner).__name__)
302+
return 1
303+
print(" device:", evidence)
304+
305+
_, dev_secs, queued = render(case, None, tee)
306+
arrived = bytes(tee.seen)
307+
recycles = getattr(tee.inner, "recycles", 0)
308+
lost = getattr(tee.inner, "lost_bytes", 0)
309+
if recycles:
310+
# Not a failure on its own -- the SDL transport rebuilds a stalled
311+
# device on purpose -- but it changes what the comparison below
312+
# means, so it is said rather than swallowed.
313+
print(" the transport recycled its device %d time(s), losing "
314+
"%d bytes" % (recycles, lost))
315+
316+
if fault == "flip" and len(arrived) > 1000:
317+
arrived = arrived[:999] + bytes([arrived[999] ^ 0x01]) \
318+
+ arrived[1000:]
319+
if fault == "short":
320+
arrived = arrived[:len(arrived) // 2]
321+
322+
same = bool(reference) and reference == arrived
323+
print(" %-4s reference %7d B device %7d B writes %d (%d short) "
324+
"queued %d" % (case, len(reference), len(arrived), tee.calls,
325+
tee.short_writes, queued))
326+
print(" digest %016x / %016x %s"
327+
% (digest(reference), digest(arrived),
328+
"SAME" if same else "DIFFER"))
329+
if not same:
330+
if not reference or not arrived:
331+
print(" one side is empty: the comparison is vacuous")
332+
else:
333+
where = -1
334+
for i in range(min(len(reference), len(arrived))):
335+
if reference[i] != arrived[i]:
336+
where = i
337+
break
338+
if where >= 0:
339+
print(" first difference at byte %d (frame %d, "
340+
"%.3f s)"
341+
% (where, where // 4, where / (RATE * 4.0)))
342+
else:
343+
print(" one is a prefix of the other: %d bytes short"
344+
% abs(len(reference) - len(arrived)))
345+
print(" wall: reference %.2fs device %.2fs"
346+
% (ref_secs, dev_secs))
347+
ok = same and ok
348+
print("VERDICT:", "the pump's bytes reached a real device unchanged"
349+
if ok else "NOT identical at the device layer")
350+
print(" (a digest cannot hear. Whether it SOUNDS right is unheard.)")
351+
return 0 if ok else 1
352+
353+
354+
sys.exit(main())

0 commit comments

Comments
 (0)