Skip to content

Commit 2945d7e

Browse files
authored
A fault the pump has been torn down for stops turning players away (#49)
* A fault the pump has already been torn down for stops turning players away `Pump._fault` was both the sentence saying what happened and the refusal that stopped every later `play()`, and only a fresh boot cleared it. So one released node took the pump away for the rest of the boot: the speaker came back on `machine.I2S`, `AudioOut.pumped` read False, the DMA counter never moved and nothing anywhere said why (#45). They come apart. `fault()` is the record and survives; `blocked()` is whether it is still in the way, and the teardown that repairs the pump -- no clients, no thread, no driver, no channel -- clears it, as does the new `clear_fault()`. A `play()` that fails with nothing left sounding tears the pump down itself rather than leaving a refusal nobody can see. And a player that does fall back because the pump is still holding something now says so once per fault, the way a board with no `wire=` does. `play(..., raises=True)` is for the caller that asked for the pump: `audiobusio.I2SOut.retarget` raises a `ValueError` naming which refusal it is -- a rate change, or a sample that is not signed 16-bit -- and flattening it to a sentence was all a caller could ever see (audioif#2). `refusal()` hands the same object to callers that take the `False`; `attach_stream()` now raises the driver's exception instead of a `RuntimeError` quoting it. Each of the three new tests was watched failing against its own planted defect first. * tests: the board probe that pulled an MP3 through the pump #40 asked for a prefetcher in front of `MP3Decoder` and an MP3 played on a board through `audiodev`. The prefetcher was already there -- `FILE_BACKED` names `MP3Decoder` beside `WaveFile` -- and what had never happened is anybody running it. The probe reports the four things the issue needs: whether the pump took it, whether a Prefetch was built, whether the DMA clocked the bytes, and what it sounded like as a digest. `digest()` beside it is the reproducible one, over a fixed 128 blocks, because the pump's own STATUS_DIGEST covers whatever it happened to pull before somebody stopped it and moves between runs. Its comments carry the three traps that each produced a confident wrong reading first: the status digest is published at run-end, the status block belongs to the I2SOut and goes when `stop()` closes it, and `play()` retunes the bus and zeroes the DMA counter under a baseline taken a line earlier. * pump: a teardown that raises must not replace the refusal it was cleaning up after `_play`'s except path tears the pump down when nothing is left sounding. If that teardown raised, the exception the caller actually needs -- the driver's own `ValueError` naming which refusal it is -- would be replaced by whatever went wrong on the way out, and under `raises=True` that is the one they would catch.
1 parent e2bce5c commit 2945d7e

4 files changed

Lines changed: 334 additions & 11 deletions

File tree

lib/audiodev/pump.py

Lines changed: 103 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,12 +1003,21 @@ def attach_stream(fmt, *, driver=None, frames=256, capacity=6):
10031003
Returns a :class:`PumpOutput` the caller writes PCM into, already
10041004
registered as a pump client -- so an ``AudioOut`` graph and this stream
10051005
are summed by a root Mixer and come out of one peripheral.
1006+
1007+
This caller DID ask for the pump, so it plays with ``raises=True`` and a
1008+
refusal arrives as the driver's own exception -- the ``ValueError`` that
1009+
says whether it was the rate or the sample width -- rather than as a
1010+
``RuntimeError`` carrying a copy of its wording.
10061011
"""
10071012
stream = PumpOutput(fmt, frames=frames, capacity=capacity)
10081013
_enter()
10091014
try:
1010-
took = owner().play(stream, stream.ring, driver=driver)
1011-
finally:
1015+
took = owner().play(stream, stream.ring, driver=driver, raises=True)
1016+
except Exception:
1017+
_leave()
1018+
stream.deinit()
1019+
raise
1020+
else:
10121021
_leave()
10131022
if not took:
10141023
stream.deinit()
@@ -1054,6 +1063,15 @@ def __init__(self):
10541063
self._retired = None
10551064
self._spawned = False
10561065
self._fault = None
1066+
# The sentence is what happened; this is whether it is still in the
1067+
# way. They came apart because a fault that had already been torn
1068+
# down went on refusing every later `play()` for the rest of the
1069+
# boot, silently, and the speaker came back on `machine.I2S`
1070+
# (pydevices#45). A pump with no clients, no thread and no driver has
1071+
# nothing broken left to carry, so `_shutdown` clears this and keeps
1072+
# the sentence for whoever wants to report it.
1073+
self._blocked = False
1074+
self._refusal = None
10571075
self._events = None
10581076

10591077
# --- the clock -------------------------------------------------------
@@ -1106,16 +1124,29 @@ def _find(self, owner):
11061124
def clients(self):
11071125
return len(self._clients)
11081126

1109-
def play(self, owner, sample, driver=None, loop=False):
1110-
"""Put *owner*'s *sample* on the pump. Returns True when it sounds."""
1127+
def play(self, owner, sample, driver=None, loop=False, raises=False):
1128+
"""Put *owner*'s *sample* on the pump. Returns True when it sounds.
1129+
1130+
A refusal is ``False`` and a sentence on :meth:`fault`, because the
1131+
usual caller is a player that never asked for the pump and cannot be
1132+
made to handle its problems.
1133+
1134+
``raises=True`` is for the caller that DID ask. The driver's own
1135+
exception comes out instead of being flattened into that sentence --
1136+
`audiobusio.I2SOut.retarget` raises a ``ValueError`` naming *which*
1137+
refusal it is, a rate change or a sample that is not signed 16-bit,
1138+
and until this keyword existed there was no way to catch it
1139+
(audioif#2). :meth:`refusal` is the same object for the callers that
1140+
take the ``False``.
1141+
"""
11111142
_enter()
11121143
try:
1113-
return self._play(owner, sample, driver, loop)
1144+
return self._play(owner, sample, driver, loop, raises)
11141145
finally:
11151146
_leave()
11161147

1117-
def _play(self, owner, sample, driver, loop):
1118-
if self._fault is not None:
1148+
def _play(self, owner, sample, driver, loop, raises=False):
1149+
if self._blocked:
11191150
return False
11201151
client = self._find(owner)
11211152
if client is None:
@@ -1131,7 +1162,23 @@ def _play(self, owner, sample, driver, loop):
11311162
self._retarget()
11321163
except Exception as exc: # noqa: BLE001 - reported, not raised
11331164
self._clients.remove(client)
1134-
self._fault = str(exc)
1165+
self._note(exc)
1166+
if not self._clients:
1167+
# Nothing is sounding, so there is nothing half-swapped to
1168+
# protect: tear the pump down, which is also what clears the
1169+
# block. The next play() gets a fresh driver and a fresh
1170+
# channel rather than a refusal it cannot see.
1171+
#
1172+
# Guarded, because a teardown that raises here would replace
1173+
# the refusal the caller actually needs to see with whatever
1174+
# went wrong on the way out -- and under `raises=True` that is
1175+
# the exception they would get.
1176+
try:
1177+
self._shutdown()
1178+
except Exception: # noqa: BLE001 - the refusal outranks it
1179+
pass
1180+
if raises:
1181+
raise
11351182
return False
11361183
return True
11371184

@@ -1155,7 +1202,7 @@ def _stop(self, owner):
11551202
except Exception as exc: # noqa: BLE001 - a client leaving must not
11561203
# take the others' audio out with it, and it must not raise inside
11571204
# somebody else's close().
1158-
self._fault = str(exc)
1205+
self._note(exc)
11591206
self.shutdown()
11601207

11611208
def pause(self, owner):
@@ -1311,11 +1358,51 @@ def died(self):
13111358
return why or "the pump stopped (error %d, fault %d)" % (w[5], w[24])
13121359

13131360
def fault(self):
1314-
"""The reason the pump is not usable for the rest of this process."""
1361+
"""The last reason the pump stopped or refused a graph, or None.
1362+
1363+
It is a record, not a verdict: it survives the teardown that repairs
1364+
the pump, so an app can still say what happened after the speaker has
1365+
come back. :meth:`blocked` is the one to ask before deciding whether
1366+
to reach for the pump at all.
1367+
"""
13151368
return self._fault
13161369

1370+
def refusal(self):
1371+
"""The exception behind :meth:`fault`, or None.
1372+
1373+
`audiobusio.I2SOut.retarget` raises a ``ValueError`` naming which
1374+
refusal it is; flattening it to `str()` was the whole of what a
1375+
caller could see (audioif#2). A caller that took the ``False`` can
1376+
re-raise this or test its type.
1377+
"""
1378+
return self._refusal
1379+
1380+
def blocked(self):
1381+
"""True while the pump will refuse every :meth:`play`.
1382+
1383+
Set when a fault lands on a pump that is still holding something --
1384+
a thread, a channel, a half-swapped tail. Cleared by the teardown,
1385+
which is the repair, or by :meth:`clear_fault`.
1386+
"""
1387+
return self._blocked
1388+
1389+
def clear_fault(self):
1390+
"""Forget the last fault and let the next :meth:`play` try again."""
1391+
was = self._fault
1392+
self._fault = None
1393+
self._refusal = None
1394+
self._blocked = False
1395+
return was
1396+
1397+
def _note(self, exc):
1398+
self._fault = str(exc)
1399+
self._refusal = exc
1400+
self._blocked = True
1401+
13171402
def note_fault(self, why):
13181403
self._fault = why
1404+
self._refusal = None
1405+
self._blocked = True
13191406

13201407
def shutdown(self):
13211408
_enter()
@@ -1340,6 +1427,12 @@ def _shutdown(self):
13401427
mod.shutdown()
13411428
self._spawned = False
13421429
self._driver = None
1430+
# The teardown IS the repair. Whatever the fault was, what is left
1431+
# after this has no clients, no thread, no driver and no channel, so
1432+
# there is nothing for the next play() to trip over. The sentence
1433+
# stays on `fault()` for whoever wants to report it; what goes is the
1434+
# refusal that used to outlive it (pydevices#45).
1435+
self._blocked = False
13431436
if driver is not None:
13441437
driver.close()
13451438

lib/audiodev/sample_out.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ def ticks_diff(a, b):
6262
#: than a global so the check costs no `global` statement on the play path.
6363
_WARNED = []
6464

65+
#: The fault sentence the "the pump is still down" note was last said about.
66+
#: Once per fault rather than once per boot: a second, different fault later
67+
#: in the same boot is news, and the same one said on every play() is not.
68+
_SAID = []
69+
6570
#: `audiodev.pump`, imported once, lazily. Lazily because importing it from
6671
#: module scope re-enters `audiodev/__init__` while THIS module is halfway
6772
#: through being imported by it; once because the latch below is read on
@@ -315,7 +320,19 @@ def _attach_pump(self, sample):
315320
if not _pump_mod.available():
316321
return
317322
engine = _pump_mod.owner()
318-
if engine.fault() is not None:
323+
if engine.blocked():
324+
# The pump is still holding whatever broke. Falling back is
325+
# right; falling back SILENTLY is what cost a board sitting an
326+
# hour -- the speaker works, `pumped` is False, the DMA counter
327+
# never moves and nothing anywhere says why (pydevices#45). Same
328+
# shape as the no-`wire=` note below, said once per fault.
329+
self._pump_refused = engine.fault()
330+
if not _SAID or _SAID[-1] != self._pump_refused:
331+
del _SAID[:]
332+
_SAID.append(self._pump_refused)
333+
print("audiodev: the audio pump is still down -",
334+
self._pump_refused,
335+
"- playing on the interpreter thread instead")
319336
return
320337
try:
321338
tail = sample

tests/pump_probes/mp3_pump.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""pydevices#40: an MP3 pulled through the pump, with a prefetcher in front.
2+
3+
`audiodev.pump.FILE_BACKED` already names `MP3Decoder` beside `WaveFile`, so
4+
`AudioOut._attach_pump` should build a `Prefetch` for it exactly as it does
5+
for a WAV. Nobody had ever run it. This plays one MP3 through
6+
`board_peripherals.audio_out()` -- the factory a user calls -- and asks four
7+
things the issue needs answered:
8+
9+
* did the pump take it, or did it fall back to machine.I2S
10+
* is there a Prefetch in front of the decoder
11+
* did the DMA actually clock the audio out
12+
* what did it sound like, as the pump's own FNV-1a digest over every byte
13+
14+
Volume is low on purpose: nobody is listening and the numbers are counters.
15+
"""
16+
import gc
17+
import struct
18+
import time
19+
20+
import audiomp3
21+
import audiopump
22+
import _audioif
23+
import board_peripherals as bp
24+
from audiodev import pump as apump
25+
26+
PATH = "/beat.mp3"
27+
VOLUME = 20
28+
29+
30+
def _dma():
31+
return _audioif.i2s_dma_bytes()
32+
33+
34+
def play(path=PATH, seconds=6.0, volume=VOLUME):
35+
gc.collect()
36+
dev = bp.audio_out()
37+
dev.set_volume(volume)
38+
fh = open(path, "rb")
39+
dec = audiomp3.MP3Decoder(fh)
40+
print("decoder: %d Hz, %d ch, %d bits"
41+
% (dec.sample_rate, dec.channel_count, dec.bits_per_sample))
42+
print("pumpable(decoder):", apump.pumpable(dec))
43+
44+
dev.play(dec)
45+
# AFTER the play, not before it. `play()` retunes the bus to the sample's
46+
# rate, which re-opens the channel and zeroes the DMA counter -- so a
47+
# baseline taken a line earlier makes the difference come out NEGATIVE
48+
# and the run look like a board that clocked nothing. Both readings have
49+
# to sit inside one open.
50+
before = _dma()
51+
prefetch = dev._prefetch
52+
print("pumped=%s prefetch=%s refused=%r"
53+
% (dev.pumped, prefetch is not None, dev.pump_refused))
54+
55+
end = time.ticks_add(time.ticks_ms(), int(seconds * 1000))
56+
ticks = 0
57+
while time.ticks_diff(end, time.ticks_ms()) > 0:
58+
dev.service()
59+
ticks += 1
60+
time.sleep_ms(20)
61+
if not dev.playing:
62+
break
63+
clocked = _dma() - before
64+
starved = (_audioif.i2s_starved_bytes()
65+
if hasattr(_audioif, "i2s_starved_bytes") else -1)
66+
pumped = dev.pumped
67+
playing = dev.playing
68+
wrote = prefetch.wrote if prefetch is not None else 0
69+
fed = prefetch.fed() if prefetch is not None else None
70+
# Two traps, one line apart. STATUS_DIGEST is published by the pump's
71+
# RUN-END, so reading the block while it plays gives a truthful zero that
72+
# looks exactly like a pump that hashed nothing. And on a board the block
73+
# belongs to the `I2SOut`, which `stop()` closes -- so asking for it after
74+
# the stop gets `audiodev`'s own spare, which nothing ever wrote, and
75+
# reads as zeros again. Hold the REFERENCE across the stop: the C side
76+
# writes the digest into this very bytearray on its way out.
77+
st = apump.owner().status()
78+
dev.stop()
79+
w = struct.unpack("<%dQ" % audiopump.STATUS_WORDS, st)
80+
print("blocks=%d bytes=%d digest=%016x" % (w[0], w[1], w[2]))
81+
print("DMA +%d bytes starved=%d sink_timeouts=%d err=%d fault=%d"
82+
% (clocked, starved, w[14], w[5], w[24]))
83+
print("prefetch: wrote=%d fed=%s" % (wrote, fed))
84+
print("pumped=%s playing=%s after %d ticks" % (pumped, playing, ticks))
85+
dev.close()
86+
fh.close()
87+
gc.collect()
88+
ok = bool(pumped) and clocked > 0 and w[0] > 0 and w[5] == 0 and w[24] == 0
89+
print("MP3 %s" % ("OK" if ok else "FAILED"))
90+
return ok
91+
92+
93+
def digest(path=PATH, blocks=128):
94+
"""The decode's own identity: sha256 over a FIXED number of blocks.
95+
96+
The pump's `STATUS_DIGEST` covers everything it happened to pull before
97+
somebody stopped it, so two runs of the same file give two different
98+
numbers -- 1038 blocks and 1039 blocks are not the same audio. This is
99+
the reproducible one, over the same first `blocks` blocks, pulled
100+
straight through `audiocore` the way `Prefetch` pulls it.
101+
102+
sha256 rather than the pump's FNV-1a on purpose: the same hash in Python
103+
is a per-byte loop, and 128 blocks of it outran `exec`'s quiet timeout on
104+
this board without printing anything. The C hash is the one that returns.
105+
"""
106+
import audiocore
107+
import binascii
108+
import hashlib
109+
gc.collect()
110+
fh = open(path, "rb")
111+
dec = audiomp3.MP3Decoder(fh)
112+
audiocore.reset_buffer(dec)
113+
h = hashlib.sha256()
114+
got = 0
115+
n = 0
116+
while n < blocks:
117+
result, buf = audiocore.get_buffer(dec)
118+
if buf is None or len(buf) == 0:
119+
break
120+
h.update(bytes(buf))
121+
got += len(buf)
122+
n += 1
123+
if result == 0: # GET_BUFFER_DONE
124+
break
125+
fh.close()
126+
out = binascii.hexlify(h.digest()).decode()[:16]
127+
gc.collect()
128+
print("offline: %d blocks, %d bytes, digest=%s" % (n, got, out))
129+
return out

0 commit comments

Comments
 (0)