From ff69f22ebae24f826137867a0eac7c76528e1fff Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:34:04 -0500 Subject: [PATCH 1/8] cast: sound on the Miracast cast, from the P4's pump or a desktop's speaker CastDisplay(audio=True) carries the app's sound with the picture. On the P4, castif reads the audio pump through a TapFeed. On a desktop, pycast muxes castif's LPCM track itself: display.pcm_out() is an audiodev PCMOutput whose writes go out on the cast, and audio='file.mp3' (or ffmpeg input arguments) casts anything ffmpeg reads. The LPCM mux is byte-identical to castif's C muxer (tests/data golden), and a unit-test step now runs in CI. --- .github/workflows/manifests.yml | 5 + lib/examples/cast/cast_display.py | 50 +++- lib/examples/cast/pycast.py | 406 +++++++++++++++++++++++++- lib/examples/miracast/board_config.py | 15 +- tests/data/tsmux_lpcm_golden.hex | 1 + tests/test_pycast.py | 97 ++++++ 6 files changed, 551 insertions(+), 23 deletions(-) create mode 100644 tests/data/tsmux_lpcm_golden.hex create mode 100644 tests/test_pycast.py diff --git a/.github/workflows/manifests.yml b/.github/workflows/manifests.yml index 70f1b87..a2a53a1 100644 --- a/.github/workflows/manifests.yml +++ b/.github/workflows/manifests.yml @@ -13,6 +13,7 @@ on: - 'scripts/install_*' - 'scripts/gallery_generator.py' - 'scripts/audit_board_config_pairs.py' + - 'tests/**' - '.github/workflows/manifests.yml' pull_request: paths: @@ -24,6 +25,7 @@ on: - 'scripts/install_*' - 'scripts/gallery_generator.py' - 'scripts/audit_board_config_pairs.py' + - 'tests/**' - '.github/workflows/manifests.yml' workflow_dispatch: @@ -64,3 +66,6 @@ jobs: - name: Audit CP/MP board_config pairs run: python scripts/audit_board_config_pairs.py + + - name: Unit tests + run: python -m unittest discover -s tests -v diff --git a/lib/examples/cast/cast_display.py b/lib/examples/cast/cast_display.py index 6121bb9..5a0e2b9 100644 --- a/lib/examples/cast/cast_display.py +++ b/lib/examples/cast/cast_display.py @@ -17,6 +17,13 @@ # Display session runs on a thread and rejoins by itself if the sink goes # away, and drawing costs what drawing into RAM costs. Desktop MicroPython has # no H.264 encoder, by decision, so there it raises. +# +# Sound (audio=True): on the P4, castif reads the audio pump's output in C, so +# whatever the board plays is on the cast. On a desktop, the app's speaker is +# the cast: display.pcm_out(format) is an audiodev PCMOutput whose sound goes +# out with the picture (../miracast/board_config.py hands it to audiodev, so +# board_peripherals.audio_out() plays there). A desktop can also cast any +# sound ffmpeg reads: audio="song.mp3", or ffmpeg input arguments as a list. import sys import time @@ -50,10 +57,19 @@ class CastDisplay(FBDisplay): fps, bitrate: the stream's. name: what the sink lists the cast as. log: where session messages go (default: nowhere). + audio: None for a silent cast. True carries the app's sound: on the + P4 what the audio pump plays (24 kHz mono, the panel's own + format, unless ``audio_format`` says otherwise), on a desktop what + the app writes to ``pcm_out()``. On a desktop, a file, URL or list + of ffmpeg input arguments casts that instead. + audio_format: (rate, channels) of the P4's pump, for audio=True. + audio_loop: play a desktop's audio file over and over. A file starts + from its beginning with each session, once the sink is listening. """ def __init__(self, sink, width=720, height=720, kind="windows", canvas=None, - fps=30, bitrate=3_000_000, name="PyDevices P4", log=None): + fps=30, bitrate=3_000_000, name="PyDevices P4", log=None, + audio=None, audio_format=(24000, 1), audio_loop=False): if kind not in ("windows", "roku"): raise ValueError("kind is 'windows' or 'roku'") self._buf = bytearray(width * height * 2) @@ -65,11 +81,19 @@ def __init__(self, sink, width=720, height=720, kind="windows", canvas=None, self.kind = kind self.name = name self.log = log or (lambda *a: None) + self.audio = None # the P4's TapFeed, or the desktop's pycast.CastAudio if castfast is not None: self.cast = castfast.make_caster(width, height, canvas=canvas or (width, height), - fps=fps, bitrate=bitrate) + fps=fps, bitrate=bitrate, audio=bool(audio)) + if audio: + self.audio = castfast.TapFeed(rate=audio_format[0], channels=audio_format[1], log=self.log) else: - self.cast = pycast.FfmpegCaster(width, height, canvas=canvas, fps=fps, bitrate=bitrate) + if audio: + self.audio = pycast.CastAudio() + self.cast = pycast.FfmpegCaster(width, height, canvas=canvas, fps=fps, bitrate=bitrate, + audio=self.audio, + audio_source=None if audio in (None, False, True) else audio, + audio_loop=audio_loop) self.uibc = UibcInput(width, height) self.sessions = 0 self._session = None @@ -109,8 +133,20 @@ def stats(self): st = self.cast.stats() s = self._session idr = self._idr_before + (s.idr_requests if s is not None else 0) - return {"frames": st.get("frames", 0), "fps": st.get("fps", 0) / 1000.0, - "sent": st.get("sent", 0), "sessions": self.sessions, "idr_requests": idr} + out = {"frames": st.get("frames", 0), "fps": st.get("fps", 0) / 1000.0, + "sent": st.get("sent", 0), "sessions": self.sessions, "idr_requests": idr} + for k in st: + if k.startswith("audio_"): # the sound track's counters (castif's names) + out[k] = st[k] + return out + + def pcm_out(self, format=None, **kwargs): + """A desktop's speaker on the cast: an audiodev PCMOutput (any 16-bit + rate, mono or stereo) whose sound goes out with the picture. Needs + audio=True. On the P4 the board's own speaker is already on the cast.""" + if pycast is None or self.audio is None: + raise RuntimeError("pcm_out needs CastDisplay(..., audio=True) on a desktop") + return pycast.CastAudioOut(self.audio, format) # touch_read / keypad_read for a board_config: the laptop's mouse (left # button held) and keys @@ -154,7 +190,7 @@ def make(dst_ip, dst_port, server_port): return pycast.FfmpegStreamer(self.cast, self._buf, dst_ip, dst_port, server_port, 24 * 3600, self.log) return castfast.CastifStreamer(self.cast, self._buf, dst_ip, dst_port, - server_port, 24 * 3600, self.log) + server_port, 24 * 3600, self.log, audio=self.audio) try: result = s.run(make, seconds=24 * 3600, idle_after_done=2, stop=lambda: self._stop) @@ -178,6 +214,8 @@ def close(self): if not self._running: break time.sleep(0.1) + if castfast is not None and self.audio is not None: + self.audio.close() self.cast.close() def deinit(self): diff --git a/lib/examples/cast/pycast.py b/lib/examples/cast/pycast.py index 7f0ae75..9dd7fba 100644 --- a/lib/examples/cast/pycast.py +++ b/lib/examples/cast/pycast.py @@ -10,7 +10,12 @@ # advertised. That matters: Windows' receiver checks the stream strictly, and # castif's is a stream it is known to accept. # -# Silent, as CastDisplay is: the PMT lists the video only. +# Sound, when the caster has a CastAudio: castif's LPCM track, 48 kHz stereo +# in 10 ms blocks on the video's clock. It is filled by an app writing PCM +# (CastAudioOut, which audiodev hands an app as its speaker) or by a second +# ffmpeg reading any input ffmpeg can open (FfmpegAudioInput: a file, a URL, +# a microphone). Without one the PMT lists the video only. +import collections import shutil import socket import subprocess @@ -19,7 +24,12 @@ PID_PMT = 0x1000 PID_VIDEO = 0x100 +PID_AUDIO = 0x101 PCR_LEAD = 36000 # 400 ms in 90 kHz ticks +RATE = 48000 # the cast's LPCM: 48 kHz stereo, 16-bit +BLOCK_BYTES = 1920 # one 10 ms block +BLOCK_S = 0.01 +REJOIN = 9000 # a gap past 100 ms re-anchors the track (castif's) def _crc32_mpeg(data): @@ -43,13 +53,18 @@ def _pts_field(pts): class TsMux: - """tsmux_core.c, video only: each call returns the 188-byte packets.""" + """tsmux_core.c: each call returns the 188-byte packets. ``lpcm=True`` + adds castif's LPCM stream to the PMT.""" - def __init__(self): - self.cc = {0: 0, PID_PMT: 0, PID_VIDEO: 0} + def __init__(self, lpcm=False): + self.cc = {0: 0, PID_PMT: 0, PID_VIDEO: 0, PID_AUDIO: 0} pat = _section(0x00, 1, bytes([0, 1, 0xE0 | (PID_PMT >> 8), PID_PMT & 0xFF])) - pmt = _section(0x02, 1, bytes([0xE0 | (PID_VIDEO >> 8), PID_VIDEO & 0xFF, 0xF0, 0x00, - 0x1B, 0xE0 | (PID_VIDEO >> 8), PID_VIDEO & 0xFF, 0xF0, 0x00])) + es = bytes([0xE0 | (PID_VIDEO >> 8), PID_VIDEO & 0xFF, 0xF0, 0x00, + 0x1B, 0xE0 | (PID_VIDEO >> 8), PID_VIDEO & 0xFF, 0xF0, 0x00]) + if lpcm: + # HDMV LPCM, with the registration descriptor castif sends + es += bytes([0x83, 0xE0 | (PID_AUDIO >> 8), PID_AUDIO & 0xFF, 0xF0, 0x04, 0x83, 0x02, 0x46, 0x2F]) + pmt = _section(0x02, 1, es) self.pat_body = (b"\x00" + pat).ljust(184, b"\xff") self.pmt_body = (b"\x00" + pmt).ljust(184, b"\xff") @@ -94,6 +109,259 @@ def video(self, au, pts, key): first = False return out + def lpcm(self, pcm, pts): + """One block of 48 kHz stereo s16 little-endian PCM as an LPCM PES: + tsmux_lpcm, byte-swapped to the big-endian samples LPCM carries.""" + pts &= 0xFFFFFFFF + n = len(pcm) + 4 + head = bytes([0, 0, 1, 0xBD, ((n + 8) >> 8) & 0xFF, (n + 8) & 0xFF, 0x80, 0x80, 5]) + _pts_field(pts) + \ + bytes([0xA0, 6, 0, 0x11]) # LPCM audio header: 48 kHz, 16-bit, stereo + swapped = bytearray(len(pcm)) + swapped[0::2] = pcm[1::2] + swapped[1::2] = pcm[0::2] + pes = head + swapped + out = [] + pos, total, first = 0, len(pes), True + while pos < total: + remaining = total - pos + if remaining < 184: + pad = 184 - remaining + af = bytes([pad - 1]) + ((b"\x00" + b"\xff" * (pad - 2)) if pad >= 2 else b"") + pkt = self._head(PID_AUDIO, first, 3) + af + pes[pos:] + pos = total + else: + pkt = self._head(PID_AUDIO, first, 1) + pes[pos:pos + 184] + pos += 184 + out.append(pkt) + first = False + return out + + +class CastAudio: + """The cast's sound track: 10 ms blocks of 48 kHz stereo s16 PCM, each + stamped with the time.monotonic() at which its first sample should be + heard. Producers ``feed`` it; the streamer takes one block every 10 ms + and muxes it as LPCM, the way castif's task drains its ring.""" + + def __init__(self, capacity=64): + self.capacity = capacity # 640 ms, castif's ring + self.blocks = collections.deque() + self.lock = threading.Lock() + self.carry = b"" + self.carry_t = None + self.fed = 0 + self.full = 0 # oldest blocks let go because the ring was full + + def feed(self, pcm, stamp=None): + """Add PCM (48 kHz stereo s16le, any length). ``stamp`` is when its + first sample is due (default: now).""" + if stamp is None: + stamp = time.monotonic() + with self.lock: + if self.carry: + stamp -= len(self.carry) / (RATE * 4) + pcm = self.carry + bytes(pcm) + pos = 0 + while pos + BLOCK_BYTES <= len(pcm): + if len(self.blocks) >= self.capacity: + # nobody is draining (no session yet) or the producer ran + # ahead: keep the newest sound, as the pump's tap does + self.blocks.popleft() + self.full += 1 + self.blocks.append((bytes(pcm[pos:pos + BLOCK_BYTES]), stamp + pos / (RATE * 4))) + self.fed += 1 + pos += BLOCK_BYTES + self.carry = bytes(pcm[pos:]) + + def take(self): + with self.lock: + return self.blocks.popleft() if self.blocks else None + + def waiting(self): + return len(self.blocks) + + +def _resampler(rate, channels): + """A function turning s16le PCM at (rate, channels) into 48 kHz stereo, + by linear interpolation, carrying its position across calls.""" + import array + + if rate == RATE and channels == 2: + return bytes + step = rate / RATE + state = {"pos": 0.0, "prev": [0] * channels} + + def convert(b): + src = array.array("h") + src.frombytes(bytes(b)) + frames = len(src) // channels + if not frames: + return b"" + prev = state["prev"] + out = array.array("h") + pos = state["pos"] # in input frames; below 0 interpolates from the last call's frame + while pos <= frames - 1: + i = int(pos // 1) + f = pos - i + for c in range(channels): + a = prev[c] if i < 0 else src[i * channels + c] + v = a if f == 0 else int(a + (src[(i + 1) * channels + c] - a) * f) + out.append(v) + if channels == 1: + out.append(v) + pos += step + state["prev"] = [src[(frames - 1) * channels + c] for c in range(channels)] + state["pos"] = pos - frames + return out.tobytes() + + return convert + + +try: + from audiodev import AudioFormat as _AudioFormat + from audiodev import PCMOutput as _PCMOutput +except ImportError: # no PyDevices: a plain object with the same methods + _AudioFormat = None + + class _PCMOutput: + def __init__(self, format, **kwargs): + self.format = format + self.is_open = False + + def open(self): + self.is_open = True + return self + + def write(self, buf): + self.open() + done = 0 + view = memoryview(buf) + while done < len(view): + done += self._write(view[done:]) + return len(buf) + + def close(self): + self.is_open = False + + +class _Format: + def __init__(self, rate, channels, bits=16): + self.rate, self.channels, self.bits = rate, channels, bits + self.signed = True + self.frame_size = channels * bits // 8 + + +class CastAudioOut(_PCMOutput): + """A speaker whose sound goes out on the cast: an audiodev PCMOutput, so + an app (or ``audiodev``'s ``audio_out`` player) writes to it as to any + sound card. Writes are paced by the wall clock, a little ahead of it, the + way a sound card's buffer paces them; any 16-bit rate and channel count is + converted to the cast's 48 kHz stereo.""" + + def __init__(self, audio, format=None, lead_ms=80, **kwargs): + if format is None: + format = _AudioFormat(RATE, 2, 16) if _AudioFormat else _Format(RATE, 2) + if getattr(format, "bits", 16) != 16: + raise ValueError("the cast takes 16-bit PCM") + super().__init__(format) + self.audio = audio + self.lead = lead_ms / 1000 + self.bps = format.rate * format.channels * 2 + self._convert = _resampler(format.rate, format.channels) + self.t0 = None # when the first byte written is due + self.written = 0 # bytes accepted since t0 + self.gaps = 0 # times the writer fell behind and the clock restarted + + def _due(self, now): + return self.t0 + self.written / self.bps - now # seconds of sound queued ahead + + def _write(self, buf): + frame = self.format.channels * 2 + while True: + now = time.monotonic() + if self.t0 is None or self._due(now) < -0.1: + if self.t0 is not None: + self.gaps += 1 # the writer stalled: the sink hears a gap, then resumes + self.t0, self.written = now, 0 + room = int((self.lead - self._due(now)) * self.bps) // frame * frame + if room >= frame: + break + time.sleep(min(0.01, -room / self.bps + 0.002)) + n = min(len(buf), room) + n -= n % frame + stamp = self.t0 + self.written / self.bps + self.audio.feed(self._convert(buf[:n]), stamp) + self.written += n + return n + + def queued_size(self): + if self.t0 is None: + return 0 + return max(0, int(self._due(time.monotonic()) * self.bps)) + + def service(self): + pass + + def is_active(self): + return self.queued_size() > 0 + + def close(self): + self.t0 = None + try: + super().close() + except Exception: + pass + + +class FfmpegAudioInput: + """The cast's sound from anything ffmpeg can read: ``source`` is a file + or URL (played in real time, looped with ``loop=True``), or a list of + ffmpeg input arguments, such as ``["-f", "dshow", "-i", "audio=Microphone"]`` + on Windows or ``["-f", "pulse", "-i", "default"]`` on Linux. A second + ffmpeg decodes it to the cast's PCM, stamped by its own sample count from + the first arrival: ffmpeg reads a file in a burst before settling to real + time, so arrival times would label the burst late.""" + + def __init__(self, audio, source, loop=False, ffmpeg=None): + self.audio = audio + if isinstance(source, str): + args = ["-re"] + (["-stream_loop", "-1"] if loop else []) + ["-i", source] + else: + args = list(source) + cmd = [ffmpeg or _ffmpeg(), "-loglevel", "error", "-nostdin"] + args + [ + "-vn", "-f", "s16le", "-ar", str(RATE), "-ac", "2", "-"] + self.proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stdin=subprocess.DEVNULL) + self.bytes = 0 + self.restarts = 0 # times the input fell behind real time and the clock restarted + self.running = True + threading.Thread(target=self._read, daemon=True).start() + + def _read(self): + out = self.proc.stdout + bps = RATE * 4 + t0 = None + n = 0 # bytes since t0 + while self.running: + d = out.read1(BLOCK_BYTES * 4) if hasattr(out, "read1") else out.read(BLOCK_BYTES) + if not d: + break + self.bytes += len(d) + now = time.monotonic() + if t0 is None or now - (t0 + (n + len(d)) / bps) > 0.2: + if t0 is not None: + self.restarts += 1 # a stall: what comes next is due now + t0, n = now - len(d) / bps, 0 + self.audio.feed(d, t0 + n / bps) + n += len(d) + + def close(self): + self.running = False + try: + self.proc.kill() + self.proc.wait(3) + except Exception: + pass + def _ffmpeg(): """ffmpeg: the FFMPEG environment variable's, or the one on the PATH.""" @@ -109,12 +377,22 @@ class FfmpegCaster: """castif.Cast's part of the interface CastDisplay uses: the picture's size, the stream's, and its counters.""" - def __init__(self, width, height, canvas=None, fps=30, bitrate=3_000_000): + def __init__(self, width, height, canvas=None, fps=30, bitrate=3_000_000, audio=None, + audio_source=None, audio_loop=False): self.ffmpeg = _ffmpeg() self.w, self.h = width, height self.cw, self.ch = canvas or (width, height) self.fps, self.bitrate = fps, bitrate + self.audio = audio # a CastAudio, or None for a silent cast + # what a second ffmpeg reads into it, started with each session so a + # file plays from its start when the sink is listening + self.audio_source, self.audio_loop = audio_source, audio_loop + if audio_source is not None and audio is None: + self.audio = CastAudio() self.frames = self.sent = self.stalls = 0 + self.audio_muxed = self.audio_inserted = self.audio_dropped = 0 + self.audio_rejoins = self.audio_underruns = 0 + self.audio_drift_ms = 0 self._t0 = None # the first frame sent: the rate counts from there def mark_dirty(self): @@ -122,8 +400,15 @@ def mark_dirty(self): def stats(self): el = time.monotonic() - self._t0 if self._t0 is not None else 0 - return {"frames": self.frames, "fps": int(1000 * self.frames / el) if el else 0, - "sent": self.sent, "stalls": self.stalls} + st = {"frames": self.frames, "fps": int(1000 * self.frames / el) if el else 0, + "sent": self.sent, "stalls": self.stalls} + if self.audio is not None: + st.update(audio_fed=self.audio.fed, audio_muxed=self.audio_muxed, + audio_level=self.audio.waiting(), audio_underruns=self.audio_underruns, + audio_drift_ms=self.audio_drift_ms, audio_inserted=self.audio_inserted, + audio_dropped=self.audio_dropped, audio_rejoins=self.audio_rejoins, + audio_full=self.audio.full) + return st def close(self): pass @@ -139,7 +424,10 @@ def __init__(self, caster, fb, dst_ip, dst_port, server_port, seconds, log): self.seconds = seconds self.done = False self.idle_poll = True - self.mux = TsMux() + self.mux = TsMux(lpcm=caster.audio is not None) + self.audio = caster.audio + self.send_lock = threading.Lock() # the picture and the sound share the mux and the RTP sequence + self._last_pts = 90000 self.dst = (dst_ip, dst_port) self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -163,6 +451,11 @@ def __init__(self, caster, fb, dst_ip, dst_port, server_port, seconds, log): self.running = True threading.Thread(target=self._feed, daemon=True).start() threading.Thread(target=self._read, daemon=True).start() + self.audio_in = None + if self.audio is not None: + if c.audio_source is not None: + self.audio_in = FfmpegAudioInput(self.audio, c.audio_source, loop=c.audio_loop, ffmpeg=c.ffmpeg) + threading.Thread(target=self._sound, daemon=True).start() log("ffmpeg cast -> %s:%d from :%d" % (dst_ip, dst_port, server_port)) def _pts(self): @@ -183,9 +476,82 @@ def _feed(self): nxt += period time.sleep(max(0.0, nxt - time.monotonic())) - def _send(self, packets): - for i in range(0, len(packets), 7): + def _sound(self): + """castif's pace_audio and drain_one: one block every 10 ms of wall + clock, about 60 ms behind the feed; the track's PTS runs on + contiguously from the newest block's stamp, re-anchors after a gap, + and drops or pads one block when it drifts past sync_ms from the + stamps (a microphone's clock is not this computer's).""" + c, a = self.c, self.audio + apts = None + drift = 0.0 + ref = None + since = 0 + sync = 40 * 90 + nxt = None + silence = bytes(BLOCK_BYTES) + while self.running: + now = time.monotonic() + if a.waiting() == 0: + if apts is not None and self._pts() - apts > 18000: + c.audio_underruns += 1 + apts = None # the producer stopped: start afresh when it comes back + nxt = None + time.sleep(0.005) + continue + if nxt is None: + nxt = now + 0.06 + if now - nxt > 0.3: + nxt = now # a long gap: don't burst-chase + if now < nxt: + time.sleep(min(0.005, nxt - now)) + continue + blk = a.take() + if blk is None: + continue + pcm, stamp = blk + if apts is None and stamp < now - 0.1: + c.audio_dropped += 1 # written before the session could play it + continue + s90 = 90000 + int((stamp - self.t0) * 90000) + if apts is None: + apts = s90 - 900 * a.waiting() # the newest waiting block carries its own stamp + drift, ref, since = 0.0, None, 0 + d = s90 - apts + if d - (ref or 0) > REJOIN: + apts, d, drift, ref, since = s90, 0, 0.0, None, 0 + c.audio_rejoins += 1 + drift += (d - drift) / 64 + c.audio_drift_ms = int(drift / 90) + if ref is None: + if since >= 300: + ref = drift + else: + off = drift - ref + if off > sync: # the sound would play early: pad + self._mux_audio(silence, apts) + apts += 900 + drift -= 900 + c.audio_inserted += 1 + elif off < -sync: # it would play late: drop this one + drift += 900 + c.audio_dropped += 1 + nxt += BLOCK_S + continue + self._mux_audio(pcm, apts) + apts += 900 + since += 1 + c.audio_muxed += 1 + nxt += BLOCK_S + + def _mux_audio(self, pcm, pts): + with self.send_lock: + self._send(self.mux.lpcm(pcm, pts), (self._pts() - PCR_LEAD) & 0xFFFFFFFF) + + def _send(self, packets, ts90=None): + if ts90 is None: ts90 = (self._last_pts - PCR_LEAD) & 0xFFFFFFFF + for i in range(0, len(packets), 7): hdr = bytes([0x80, 33, (self.seq >> 8) & 0xFF, self.seq & 0xFF]) + ts90.to_bytes(4, "big") + b"P4CA" try: self.sock.sendto(hdr + b"".join(packets[i:i + 7]), self.dst) @@ -210,9 +576,10 @@ def _au(self, au): i = au.find(b"\x00\x00\x01", i + 3) with self.lock: pts = self.pts.pop(0) if self.pts else self._pts() - self._last_pts = pts - packets = (self.mux.tables() if key else []) + self.mux.video(au, pts, key) - self._send(packets) + with self.send_lock: + self._last_pts = pts + packets = (self.mux.tables() if key else []) + self.mux.video(au, pts, key) + self._send(packets) if self.c._t0 is None: self.c._t0 = time.monotonic() self.c.frames += 1 @@ -238,7 +605,12 @@ def pump(self, budget_us): if now - getattr(self, "_beat", 0) > 3: self._beat = now s = self.c.stats() - self.log("pycast: %d frames, %.1f fps, %d sent, %d stalls" % (s["frames"], s["fps"] / 1000, s["sent"], s["stalls"])) + line = "pycast: %d frames, %.1f fps, %d sent, %d stalls" % (s["frames"], s["fps"] / 1000, s["sent"], s["stalls"]) + if self.audio is not None: + line += " | audio fed %d muxed %d level %d underruns %d drift %d ms ins %d drop %d rejoin %d full %d" % ( + s["audio_fed"], s["audio_muxed"], s["audio_level"], s["audio_underruns"], s["audio_drift_ms"], + s["audio_inserted"], s["audio_dropped"], s["audio_rejoins"], s["audio_full"]) + self.log(line) if now - self.t0 > self.seconds: self.done = True @@ -247,6 +619,8 @@ def force_idr(self): def close(self): self.running = False + if self.audio_in is not None: + self.audio_in.close() try: self.proc.stdin.close() except OSError: diff --git a/lib/examples/miracast/board_config.py b/lib/examples/miracast/board_config.py index 7b08199..3241fdb 100644 --- a/lib/examples/miracast/board_config.py +++ b/lib/examples/miracast/board_config.py @@ -40,13 +40,26 @@ KIND, SINK = SINK.split(":", 1) WIDTH = 720 HEIGHT = 720 +# the app's sound on the cast: on a P4 what its speaker plays, on a desktop +# what it plays through audiodev. False for a silent cast. +AUDIO = True if sys.implementation.name != "cpython": # a board joins Wi-Fi; a desktop is on the LAN from _common import wifi_up wifi_up() # the session's progress on the console: joining, the sink's answers, frame rates -display_drv = CastDisplay(SINK, WIDTH, HEIGHT, kind=KIND, log=print) +display_drv = CastDisplay(SINK, WIDTH, HEIGHT, kind=KIND, log=print, audio=AUDIO) + +if AUDIO and sys.implementation.name == "cpython": + # a desktop app's speaker is the cast: board_peripherals.audio_out() and + # audiodev's own pcm_out/audio_out open display_drv.pcm_out from here on + try: + import audiodev.auto + except ImportError: + pass + else: + audiodev.auto.pcm_out = display_drv.pcm_out # what appdev.App and the examples read from a board_config fb = display_drv._buf diff --git a/tests/data/tsmux_lpcm_golden.hex b/tests/data/tsmux_lpcm_golden.hex new file mode 100644 index 0000000..1e3c8be --- /dev/null +++ b/tests/data/tsmux_lpcm_golden.hex @@ -0,0 +1 @@ +474000100000b00d0001c100000001f0002ab104b2ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff475000100002b01b0001c10000e100f0001be100f00083e101f0048302462f1033d6c4ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff4741003007500000aad07e00000001e00000808005210007c4810000000109f0010e1b2835424f5c697683909daab7c4d1deebf805121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98a5b2bfccd9e6f3000d1a2734414e5b6875828f9ca9b6c3d0ddeaf704111e2b3845525f6c798693a0adbac7d4e1eefb0815222f3c495663707d8a97a4b1becbd8e5f2ff0c192633404d5a6774818e9ba8b5c2cfdce9f603101d2a3744515e6b7885929facb9c6d3e047010011edfa0714212e3b4855626f7c8996a3b0bdcad7e4f1fe0b1825323f4c596673808d9aa7b4c1cedbe8f5020f1c293643505d6a7784919eabb8c5d2dfecf90613202d3a4754616e7b8895a2afbcc9d6e3f0fd0a1724313e4b5865727f8c99a6b3c0cddae7f4010e1b2835424f5c697683909daab7c4d1deebf805121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98a5b2bfccd9e6f3000d1a2734414e5b6875828f9ca9b6c3d0ddeaf704111e2b384701001245525f6c798693a0adbac7d4e1eefb0815222f3c495663707d8a97a4b1becbd8e5f2ff0c192633404d5a6774818e9ba8b5c2cfdce9f603101d2a3744515e6b7885929facb9c6d3e0edfa0714212e3b4855626f7c8996a3b0bdcad7e4f1fe0b1825323f4c596673808d9aa7b4c1cedbe8f5020f1c293643505d6a7784919eabb8c5d2dfecf90613202d3a4754616e7b8895a2afbcc9d6e3f0fd0a1724313e4b5865727f8c99a6b3c0cddae7f4010e1b2835424f5c69768390470100139daab7c4d1deebf805121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98a5b2bfccd9e6f3000d1a2734414e5b6875828f9ca9b6c3d0ddeaf704111e2b3845525f6c798693a0adbac7d4e1eefb0815222f3c495663707d8a97a4b1becbd8e5f2ff0c192633404d5a6774818e9ba8b5c2cfdce9f603101d2a3744515e6b7885929facb9c6d3e0edfa0714212e3b4855626f7c8996a3b0bdcad7e4f1fe0b1825323f4c596673808d9aa7b4c1cedbe847010014f5020f1c293643505d6a7784919eabb8c5d2dfecf90613202d3a4754616e7b8895a2afbcc9d6e3f0fd0a1724313e4b5865727f8c99a6b3c0cddae7f4010e1b2835424f5c697683909daab7c4d1deebf805121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98a5b2bfccd9e6f3000d1a2734414e5b6875828f9ca9b6c3d0ddeaf704111e2b3845525f6c798693a0adbac7d4e1eefb0815222f3c495663707d8a97a4b1becbd8e5f2ff0c19263340470100354b00ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff4d5a6774818e9ba8b5c2cfdce9f603101d2a3744515e6b7885929facb9c6d3e0edfa0714212e3b4855626f7c8996a3b0bdcad7e4f1fe0b1825323f4c596673808d9aa7b4c1cedbe8f5020f1c293643505d6a7784919eabb8c5d2dfecf90613202d3a4754616e7b8895a2afbc47410110000001bd078c808005210007c0f1a00600110a031811261f342d423b50495e576c657a738881968fa49db2abc0b9cec7dcd5eae3f8f106ff140d221b30293e374c455a536861766f847d928ba099aea7bcb5cac3d8d1e6dff4ed02fb10091e172c253a334841564f645d726b80798e879c95aaa3b8b1c6bfd4cde2dbf0e9fef70c051a132821362f443d524b60596e677c758a839891a69fb4adc2bbd0c9ded7ece5faf30801160f241d322b40394e475c556a637871867f47010111948da29bb0a9beb7ccc5dad3e8e1f6ef04fd120b20192e273c354a435851665f746d827b90899e97aca5bab3c8c1d6cfe4ddf2eb00f90e071c152a233831463f544d625b70697e778c859a93a8a1b6afc4bdd2cbe0d9eee7fcf50a031811261f342d423b50495e576c657a738881968fa49db2abc0b9cec7dcd5eae3f8f106ff140d221b30293e374c455a536861766f847d928ba099aea7bcb5cac3d8d1e6dff4ed02fb10091e172c253a334841564f645d726b80798e87470101129c95aaa3b8b1c6bfd4cde2dbf0e9fef70c051a132821362f443d524b60596e677c758a839891a69fb4adc2bbd0c9ded7ece5faf30801160f241d322b40394e475c556a637871867f948da29bb0a9beb7ccc5dad3e8e1f6ef04fd120b20192e273c354a435851665f746d827b90899e97aca5bab3c8c1d6cfe4ddf2eb00f90e071c152a233831463f544d625b70697e778c859a93a8a1b6afc4bdd2cbe0d9eee7fcf50a031811261f342d423b50495e576c657a738881968f47010113a49db2abc0b9cec7dcd5eae3f8f106ff140d221b30293e374c455a536861766f847d928ba099aea7bcb5cac3d8d1e6dff4ed02fb10091e172c253a334841564f645d726b80798e879c95aaa3b8b1c6bfd4cde2dbf0e9fef70c051a132821362f443d524b60596e677c758a839891a69fb4adc2bbd0c9ded7ece5faf30801160f241d322b40394e475c556a637871867f948da29bb0a9beb7ccc5dad3e8e1f6ef04fd120b20192e273c354a435851665f746d827b90899e9747010114aca5bab3c8c1d6cfe4ddf2eb00f90e071c152a233831463f544d625b70697e778c859a93a8a1b6afc4bdd2cbe0d9eee7fcf50a031811261f342d423b50495e576c657a738881968fa49db2abc0b9cec7dcd5eae3f8f106ff140d221b30293e374c455a536861766f847d928ba099aea7bcb5cac3d8d1e6dff4ed02fb10091e172c253a334841564f645d726b80798e879c95aaa3b8b1c6bfd4cde2dbf0e9fef70c051a132821362f443d524b60596e677c758a839891a69f47010115b4adc2bbd0c9ded7ece5faf30801160f241d322b40394e475c556a637871867f948da29bb0a9beb7ccc5dad3e8e1f6ef04fd120b20192e273c354a435851665f746d827b90899e97aca5bab3c8c1d6cfe4ddf2eb00f90e071c152a233831463f544d625b70697e778c859a93a8a1b6afc4bdd2cbe0d9eee7fcf50a031811261f342d423b50495e576c657a738881968fa49db2abc0b9cec7dcd5eae3f8f106ff140d221b30293e374c455a536861766f847d928ba099aea747010116bcb5cac3d8d1e6dff4ed02fb10091e172c253a334841564f645d726b80798e879c95aaa3b8b1c6bfd4cde2dbf0e9fef70c051a132821362f443d524b60596e677c758a839891a69fb4adc2bbd0c9ded7ece5faf30801160f241d322b40394e475c556a637871867f948da29bb0a9beb7ccc5dad3e8e1f6ef04fd120b20192e273c354a435851665f746d827b90899e97aca5bab3c8c1d6cfe4ddf2eb00f90e071c152a233831463f544d625b70697e778c859a93a8a1b6af47010117c4bdd2cbe0d9eee7fcf50a031811261f342d423b50495e576c657a738881968fa49db2abc0b9cec7dcd5eae3f8f106ff140d221b30293e374c455a536861766f847d928ba099aea7bcb5cac3d8d1e6dff4ed02fb10091e172c253a334841564f645d726b80798e879c95aaa3b8b1c6bfd4cde2dbf0e9fef70c051a132821362f443d524b60596e677c758a839891a69fb4adc2bbd0c9ded7ece5faf30801160f241d322b40394e475c556a637871867f948da29bb0a9beb747010118ccc5dad3e8e1f6ef04fd120b20192e273c354a435851665f746d827b90899e97aca5bab3c8c1d6cfe4ddf2eb00f90e071c152a233831463f544d625b70697e778c859a93a8a1b6afc4bdd2cbe0d9eee7fcf50a031811261f342d423b50495e576c657a738881968fa49db2abc0b9cec7dcd5eae3f8f106ff140d221b30293e374c455a536861766f847d928ba099aea7bcb5cac3d8d1e6dff4ed02fb10091e172c253a334841564f645d726b80798e879c95aaa3b8b1c6bf47010119d4cde2dbf0e9fef70c051a132821362f443d524b60596e677c758a839891a69fb4adc2bbd0c9ded7ece5faf30801160f241d322b40394e475c556a637871867f948da29bb0a9beb7ccc5dad3e8e1f6ef04fd120b20192e273c354a435851665f746d827b90899e97aca5bab3c8c1d6cfe4ddf2eb00f90e071c152a233831463f544d625b70697e778c859a93a8a1b6afc4bdd2cbe0d9eee7fcf50a031811261f342d423b50495e576c657a738881968fa49db2abc0b9cec74701013a5500ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdcd5eae3f8f106ff140d221b30293e374c455a536861766f847d928ba099aea7bcb5cac3d8d1e6dff4ed02fb10091e172c253a334841564f645d726b80798e879c95aaa3b8b1c6bfd4cde2dbf0e9fef70c051a132821362f443d524b60596e677c754741011b000001bd078c808005210007c7f9a00600110a031811261f342d423b50495e576c657a738881968fa49db2abc0b9cec7dcd5eae3f8f106ff140d221b30293e374c455a536861766f847d928ba099aea7bcb5cac3d8d1e6dff4ed02fb10091e172c253a334841564f645d726b80798e879c95aaa3b8b1c6bfd4cde2dbf0e9fef70c051a132821362f443d524b60596e677c758a839891a69fb4adc2bbd0c9ded7ece5faf30801160f241d322b40394e475c556a637871867f4701011c948da29bb0a9beb7ccc5dad3e8e1f6ef04fd120b20192e273c354a435851665f746d827b90899e97aca5bab3c8c1d6cfe4ddf2eb00f90e071c152a233831463f544d625b70697e778c859a93a8a1b6afc4bdd2cbe0d9eee7fcf50a031811261f342d423b50495e576c657a738881968fa49db2abc0b9cec7dcd5eae3f8f106ff140d221b30293e374c455a536861766f847d928ba099aea7bcb5cac3d8d1e6dff4ed02fb10091e172c253a334841564f645d726b80798e874701011d9c95aaa3b8b1c6bfd4cde2dbf0e9fef70c051a132821362f443d524b60596e677c758a839891a69fb4adc2bbd0c9ded7ece5faf30801160f241d322b40394e475c556a637871867f948da29bb0a9beb7ccc5dad3e8e1f6ef04fd120b20192e273c354a435851665f746d827b90899e97aca5bab3c8c1d6cfe4ddf2eb00f90e071c152a233831463f544d625b70697e778c859a93a8a1b6afc4bdd2cbe0d9eee7fcf50a031811261f342d423b50495e576c657a738881968f4701011ea49db2abc0b9cec7dcd5eae3f8f106ff140d221b30293e374c455a536861766f847d928ba099aea7bcb5cac3d8d1e6dff4ed02fb10091e172c253a334841564f645d726b80798e879c95aaa3b8b1c6bfd4cde2dbf0e9fef70c051a132821362f443d524b60596e677c758a839891a69fb4adc2bbd0c9ded7ece5faf30801160f241d322b40394e475c556a637871867f948da29bb0a9beb7ccc5dad3e8e1f6ef04fd120b20192e273c354a435851665f746d827b90899e974701011faca5bab3c8c1d6cfe4ddf2eb00f90e071c152a233831463f544d625b70697e778c859a93a8a1b6afc4bdd2cbe0d9eee7fcf50a031811261f342d423b50495e576c657a738881968fa49db2abc0b9cec7dcd5eae3f8f106ff140d221b30293e374c455a536861766f847d928ba099aea7bcb5cac3d8d1e6dff4ed02fb10091e172c253a334841564f645d726b80798e879c95aaa3b8b1c6bfd4cde2dbf0e9fef70c051a132821362f443d524b60596e677c758a839891a69f47010110b4adc2bbd0c9ded7ece5faf30801160f241d322b40394e475c556a637871867f948da29bb0a9beb7ccc5dad3e8e1f6ef04fd120b20192e273c354a435851665f746d827b90899e97aca5bab3c8c1d6cfe4ddf2eb00f90e071c152a233831463f544d625b70697e778c859a93a8a1b6afc4bdd2cbe0d9eee7fcf50a031811261f342d423b50495e576c657a738881968fa49db2abc0b9cec7dcd5eae3f8f106ff140d221b30293e374c455a536861766f847d928ba099aea747010111bcb5cac3d8d1e6dff4ed02fb10091e172c253a334841564f645d726b80798e879c95aaa3b8b1c6bfd4cde2dbf0e9fef70c051a132821362f443d524b60596e677c758a839891a69fb4adc2bbd0c9ded7ece5faf30801160f241d322b40394e475c556a637871867f948da29bb0a9beb7ccc5dad3e8e1f6ef04fd120b20192e273c354a435851665f746d827b90899e97aca5bab3c8c1d6cfe4ddf2eb00f90e071c152a233831463f544d625b70697e778c859a93a8a1b6af47010112c4bdd2cbe0d9eee7fcf50a031811261f342d423b50495e576c657a738881968fa49db2abc0b9cec7dcd5eae3f8f106ff140d221b30293e374c455a536861766f847d928ba099aea7bcb5cac3d8d1e6dff4ed02fb10091e172c253a334841564f645d726b80798e879c95aaa3b8b1c6bfd4cde2dbf0e9fef70c051a132821362f443d524b60596e677c758a839891a69fb4adc2bbd0c9ded7ece5faf30801160f241d322b40394e475c556a637871867f948da29bb0a9beb747010113ccc5dad3e8e1f6ef04fd120b20192e273c354a435851665f746d827b90899e97aca5bab3c8c1d6cfe4ddf2eb00f90e071c152a233831463f544d625b70697e778c859a93a8a1b6afc4bdd2cbe0d9eee7fcf50a031811261f342d423b50495e576c657a738881968fa49db2abc0b9cec7dcd5eae3f8f106ff140d221b30293e374c455a536861766f847d928ba099aea7bcb5cac3d8d1e6dff4ed02fb10091e172c253a334841564f645d726b80798e879c95aaa3b8b1c6bf47010114d4cde2dbf0e9fef70c051a132821362f443d524b60596e677c758a839891a69fb4adc2bbd0c9ded7ece5faf30801160f241d322b40394e475c556a637871867f948da29bb0a9beb7ccc5dad3e8e1f6ef04fd120b20192e273c354a435851665f746d827b90899e97aca5bab3c8c1d6cfe4ddf2eb00f90e071c152a233831463f544d625b70697e778c859a93a8a1b6afc4bdd2cbe0d9eee7fcf50a031811261f342d423b50495e576c657a738881968fa49db2abc0b9cec7470101355500ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdcd5eae3f8f106ff140d221b30293e374c455a536861766f847d928ba099aea7bcb5cac3d8d1e6dff4ed02fb10091e172c253a334841564f645d726b80798e879c95aaa3b8b1c6bfd4cde2dbf0e9fef70c051a132821362f443d524b60596e677c75474100363f100000b0ac7e00ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000001e00000808005210007dbf10000000109f0010e1b2835424f5c697683909daab7c4d1deebf805121f2c394653606d7a8794a1aebbc8d5e2effc091623303d4a5764717e8b98a5b2bfccd9e6f3000d1a2734414e5b6875828f9ca9b6c3d0ddeaf704111e2b3845525f6c798693a0adbac7d4e1eefb08 diff --git a/tests/test_pycast.py b/tests/test_pycast.py new file mode 100644 index 0000000..b55133d --- /dev/null +++ b/tests/test_pycast.py @@ -0,0 +1,97 @@ +"""pycast's sound path on CPython: the LPCM mux against castif's C muxer, +the resampler, the block ring, and the paced speaker.""" +import array +import os +import sys +import time +import unittest + +HERE = os.path.dirname(__file__) +sys.path.insert(0, os.path.join(HERE, "..", "lib", "examples", "cast")) + +import pycast + +PCM = bytes((i * 7 + 3) & 0xFF for i in range(1920)) +AU = bytes((i * 13 + 1) & 0xFF for i in range(1000)) + + +def _bytes(x): + return b"".join(x) if isinstance(x, (list, tuple)) else bytes(x) + + +class TsMuxTest(unittest.TestCase): + def test_matches_castif(self): + # tsmux_lpcm_golden.hex is castif's tsmux.Muxer(lpcm=True) on the same calls + with open(os.path.join(HERE, "data", "tsmux_lpcm_golden.hex")) as f: + golden = bytes.fromhex(f.read().strip()) + m = pycast.TsMux(lpcm=True) + out = b"".join(_bytes(x) for x in ( + m.tables(), m.video(AU, 123456, True), m.lpcm(PCM, 123000), + m.lpcm(PCM, 123900), m.video(AU[:100], 126456, False))) + self.assertEqual(len(out) % 188, 0) + self.assertEqual(out, golden) + + def test_video_only_pmt_has_no_audio(self): + pmt = _bytes(pycast.TsMux().tables())[188:] + self.assertNotIn(bytes((0x83, 0xE1, 0x01)), pmt) + self.assertIn(bytes((0x83, 0xE1, 0x01)), _bytes(pycast.TsMux(lpcm=True).tables())[188:]) + + +class ResamplerTest(unittest.TestCase): + def test_passthrough(self): + self.assertEqual(pycast._resampler(48000, 2)(PCM), PCM) + + def test_24k_mono_doubles_and_duplicates(self): + conv = pycast._resampler(24000, 1) + src = array.array("h", range(0, 4800, 10)) # 480 frames, 20 ms + out = array.array("h") + for i in range(0, len(src), 37): # odd chunks: the position carries + out.frombytes(conv(src[i:i + 37].tobytes())) + self.assertAlmostEqual(len(out) // 2, 960, delta=2) + self.assertTrue(all(out[i] == out[i + 1] for i in range(0, len(out), 2))) + left = out[::2] + self.assertTrue(all(b >= a for a, b in zip(left, left[1:]))) # a ramp stays a ramp + self.assertTrue(all(b - a <= 10 for a, b in zip(left, left[1:]))) + + +class CastAudioTest(unittest.TestCase): + def test_blocks_carry_and_stamp(self): + a = pycast.CastAudio() + a.feed(PCM[:1000], 10.0) + self.assertEqual(a.waiting(), 0) + a.feed(PCM[1000:] + PCM, 10.0 + 1000 / 192000) + self.assertEqual(a.waiting(), 2) + blk, t = a.take() + self.assertEqual(blk, PCM) + self.assertAlmostEqual(t, 10.0) + self.assertAlmostEqual(a.take()[1], 10.01) + + def test_full_ring_keeps_newest(self): + a = pycast.CastAudio(capacity=4) + for i in range(6): + a.feed(PCM, float(i)) + self.assertEqual((a.waiting(), a.full, a.fed), (4, 2, 6)) + self.assertEqual(a.take()[1], 2.0) + + +class CastAudioOutTest(unittest.TestCase): + def test_paced_by_the_clock(self): + a = pycast.CastAudio(capacity=1000) + out = pycast.CastAudioOut(a, pycast._Format(48000, 2), lead_ms=80) + t = time.monotonic() + out.write(bytes(192000 * 3 // 10)) # 300 ms of sound + took = time.monotonic() - t + self.assertGreater(took, 0.18) # 300 ms less the 80 ms lead + self.assertLess(took, 0.6) + self.assertEqual(a.fed, 30) + stamps = [a.take()[1] for _ in range(30)] + self.assertAlmostEqual(stamps[-1] - stamps[0], 0.29, places=3) + self.assertEqual(out.gaps, 0) + + def test_rejects_8_bit(self): + with self.assertRaises(ValueError): + pycast.CastAudioOut(pycast.CastAudio(), pycast._Format(48000, 2, 8)) + + +if __name__ == "__main__": + unittest.main() From 5f773c71388582391e1f051f690e5baea622efbe Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:18:39 -0500 Subject: [PATCH 2/8] RokuAudioSink: the TV plays it, on every desktop runtime The TV's player waited forever on the endless WAV: it now carries a Content-Length and real RIFF sizes (an hour of sound), and the player starts. Writes are paced by the clock, 2 s ahead, counted in bytes for an array too: before, an app's sound loop ran many times faster than its sound. Unix MicroPython could not bind the port at all; it binds 0.0.0.0 by getaddrinfo now, as the frame server does. --- lib/utils/roku_companion.py | 45 ++++++++++++++++------- tests/test_roku_audio_sink.py | 67 +++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 13 deletions(-) create mode 100644 tests/test_roku_audio_sink.py diff --git a/lib/utils/roku_companion.py b/lib/utils/roku_companion.py index f56cb9a..389a8e8 100644 --- a/lib/utils/roku_companion.py +++ b/lib/utils/roku_companion.py @@ -1129,7 +1129,16 @@ def __getattr__(self, name): class RokuAudioSink: - """PCM sink for Roku TV audio streaming.""" + """PCM sink for Roku TV audio streaming: the Companion channel plays it as + a WAV over HTTP. The channel shows sound or frames, not both, and the TV's + player buffers the stream as it would a file, so the sound starts seconds + late and can stall; for an app's sound with its picture, cast it over + Miracast (examples/miracast) instead.""" + + # seconds of sound written ahead of the clock: the TV fills its buffer + # before it plays, so a longer lead starts the sound sooner (measured: 2 s + # of lead, 4.2 s behind the app; 0.5 s, 5.2 s) + LEAD = 2.0 def __init__(self, tv, port=8091, format=None, my_ip=None, **kwargs): if isinstance(tv, str): @@ -1155,16 +1164,16 @@ class Format: self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - try: - addr = socket.getaddrinfo("", self.port, socket.AF_INET)[0][-1] - except Exception: - addr = ("", self.port) - self._sock.bind(addr) + # all interfaces: MicroPython's getaddrinfo() refuses "", and its + # bind() takes only what getaddrinfo() returns + self._sock.bind(socket.getaddrinfo("0.0.0.0", self.port)[0][-1]) self._sock.listen(2) self._running = True self._client = None self._launched = False + self._t0 = None # when the first byte sent is due at the TV + self._sent = 0 try: import threading @@ -1196,7 +1205,8 @@ def poll(self): except OSError: pass - resp = b"HTTP/1.0 200 OK\r\nContent-Type: audio/wav\r\nConnection: close\r\n\r\n" + hour = self.format.rate * self.format.channels * (self.format.bits // 8) * 3600 + resp = b"HTTP/1.0 200 OK\r\nContent-Type: audio/wav\r\nContent-Length: %d\r\nConnection: close\r\n\r\n" % (hour + 44) import struct @@ -1204,7 +1214,7 @@ def poll(self): channels = self.format.channels bits = self.format.bits - header = b"RIFF\xff\xff\xff\xffWAVEfmt \x10\x00\x00\x00\x01\x00" + header = b"RIFF" + struct.pack(" 0: + time.sleep(ahead) def close(self): self._running = False diff --git a/tests/test_roku_audio_sink.py b/tests/test_roku_audio_sink.py new file mode 100644 index 0000000..cd3f188 --- /dev/null +++ b/tests/test_roku_audio_sink.py @@ -0,0 +1,67 @@ +"""RokuAudioSink: the WAV it serves the TV, and the clock that paces it.""" +import array +import os +import socket +import struct +import sys +import time +import unittest + +HERE = os.path.dirname(__file__) +sys.path.insert(0, os.path.join(HERE, "..", "lib")) + +from utils.roku_companion import RokuAudioSink + + +class _TV: + host = "127.0.0.1" + + def __init__(self): + self.launched = [] + + def _launch(self, args): + self.launched.append(args) + + +class _Format: + rate, channels, bits, signed = 24000, 1, 16, True + + +class RokuAudioSinkTest(unittest.TestCase): + def setUp(self): + self.tv = _TV() + self.sink = RokuAudioSink(self.tv, port=0, format=_Format(), my_ip="127.0.0.1") + self.port = self.sink._sock.getsockname()[1] + + def tearDown(self): + self.sink.close() + + def test_wav_has_a_length(self): + # the TV's player waits forever on a WAV with no length + c = socket.create_connection(("127.0.0.1", self.port), timeout=3) + c.sendall(b"GET /stream.wav HTTP/1.0\r\n\r\n") + data = b"" + while b"\r\n\r\n" not in data or len(data.split(b"\r\n\r\n", 1)[1]) < 44: + data += c.recv(4096) + c.close() + head, wav = data.split(b"\r\n\r\n", 1) + length = int(head.split(b"Content-Length: ")[1].split(b"\r\n")[0]) + riff, data_len = struct.unpack(" Date: Thu, 29 Oct 2026 09:20:23 -0500 Subject: [PATCH 3/8] Cast sound: the roku config's TV sound is opt-in; docs measure both paths The Companion channel plays sound or the picture, not both, so sending an app's sound there replaced its picture. SOUND_ON_TV turns it on. The guide says how to cast a desktop app's sound over Miracast, and cast-runtimes has the measured sound on each runtime. --- docs/guides/cast-to-a-tv.md | 13 ++++++++++++- docs/reference/cast-runtimes.md | 17 +++++++++++++++++ lib/examples/roku/board_config.py | 17 ++++++++++++----- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/docs/guides/cast-to-a-tv.md b/docs/guides/cast-to-a-tv.md index 0bd753f..86e4a8f 100644 --- a/docs/guides/cast-to-a-tv.md +++ b/docs/guides/cast-to-a-tv.md @@ -18,7 +18,7 @@ the [runtime table](../reference/cast-runtimes.md) says which methods work where |---|---|---|---|---|---|---| | [Miracast to a Roku](#miracast-to-a-roku-tv) | Roku TV | Screen mirroring on, nothing installed | yes, 48 kHz, in sync | no ([#168](https://github.com/PyDevices/pydevices-examples/issues/168)) | ESP32-P4 | CPython with ffmpeg | | [Miracast to Windows](#miracast-to-a-windows-laptop) | Windows laptop | the Wireless Display app open | yes | the laptop's mouse and keyboard | ESP32-P4 | CPython with ffmpeg, from another PC; glitches on a busy network | -| [The Companion channel](#the-pydevices-companion-channel) | Roku TV | the Companion channel, sideloaded once | through the TV | the TV's remote | any board with Wi-Fi and `pngio` | CPython and MicroPython | +| [The Companion channel](#the-pydevices-companion-channel) | Roku TV | the Companion channel, sideloaded once | sound or the picture, not both; seconds behind | the TV's remote | any board with Wi-Fi and `pngio` | CPython and MicroPython | | [HLS video](#hls-video-to-a-roku-or-vlc) | Roku TV, or VLC anywhere | the Companion channel, or any HLS player | no (needs AAC) | none | ESP32-P4 | CPython with ffmpeg | In short: **Miracast** for a live picture with sound, on a TV or a laptop, with @@ -48,6 +48,12 @@ about 24 fps. A P4 with no screen of its own casts the full 1280x720 at 18 fps P4 plays goes with it as 48 kHz audio on the picture's clock, so a drum machine's hits land with its step light. +From a desktop, `CastDisplay(..., audio=True)` puts the app's sound on the +cast: [`miracast/board_config.py`](https://github.com/PyDevices/gallery/blob/main/lib/examples/miracast/board_config.py) +does that, so whatever an app plays through `audiodev` comes out of the TV. +`audio="song.mp3"` casts a file instead, or any input ffmpeg can open, a +microphone included, given as a list of ffmpeg arguments. + The P4 also controls the TV over its own network API (ECP, built into every Roku, with **Control by mobile apps** on). It can power the TV on, change the input or the volume, and press any key. @@ -106,6 +112,11 @@ on it. One example each in [`lib/examples/roku/`](https://github.com/PyDevices/gallery/tree/main/lib/examples/roku/). - **Video**: `video(url)` plays HLS, which is how the next method reaches a TV. +- **Sound**: `RokuAudioSink` streams an app's sound to the TV. The channel + plays sound or shows frames, not both, and the TV buffers it, so it runs 3.5 + to 5.5 seconds behind. That suits music, not a game; for an app's sound with + its picture, use Miracast. `SOUND_ON_TV` in + [`roku/board_config.py`](https://github.com/PyDevices/gallery/blob/main/lib/examples/roku/board_config.py) turns it on. The library is plain sockets plus [`pngio`](https://github.com/PyDevices/micropython-pydevices/tree/main/modules/pngio) diff --git a/docs/reference/cast-runtimes.md b/docs/reference/cast-runtimes.md index 3cd96d3..f963695 100644 --- a/docs/reference/cast-runtimes.md +++ b/docs/reference/cast-runtimes.md @@ -24,6 +24,23 @@ on the PATH, or name it in the `FFMPEG` environment variable (Windows `python.exe` started from WSL needs that). The Miracast stream from CPython is the same MPEG-TS over RTP that the P4 sends, byte for byte. +**Sound** was measured with a TV's speakers recorded by a microphone, with +speech the recording was transcribed back from and beeps it was timed by. + +| Runtime | Miracast to a Roku, app sound | Companion channel (`RokuAudioSink`) | +|---|---|---| +| `python` (Linux) | plays, no drift, no gaps | plays, 4.2 s behind | +| `python.exe` (Windows) | plays, no drift, no gaps | plays, 5.4 s behind | +| `micropython` (unix) | no H.264 | plays, 3.5 s behind | +| `micropython.exe` (Windows) | no H.264 | plays, 4.8 to 5.2 s behind | + +Over Miracast the sound rides the picture's clock, so it is in step with the +picture. Over the Companion channel the TV plays sound or frames, not both, +and holds a few seconds of buffer: steady while the app keeps writing, but +the last seconds stay in the TV until the stream closes. Those figures include +the 2 s the sink writes ahead, and leave out the microphone's own start-up, +which makes them a little long. + **Miracast to Windows** is testris only, with glitches from Wi-Fi loss (see [Miracast to a Windows laptop](../guides/cast-to-a-tv.md#miracast-to-a-windows-laptop)). From CPython it needs a second PC as the receiver: Windows won't take a cast from itself. diff --git a/lib/examples/roku/board_config.py b/lib/examples/roku/board_config.py index d022789..2072dd0 100644 --- a/lib/examples/roku/board_config.py +++ b/lib/examples/roku/board_config.py @@ -32,9 +32,16 @@ audio_drv = None is_rotated = False -# Monkeypatch desktop audio to stream to Roku TV -import audiodev.auto -def roku_pcm_out(format=None, **kwargs): - return RokuAudioSink(tv, format=format) -audiodev.auto.pcm_out = roku_pcm_out +# An app's sound on the TV's speakers too. Off by default: the Companion +# channel shows sound or the picture, not both, so the picture gives way to +# "Streaming Audio...", and the sound runs 3.5 to 5.5 seconds behind the app. +# For sound with the picture, use ../miracast/board_config.py. +SOUND_ON_TV = False +if SOUND_ON_TV: + import audiodev.auto + + def roku_pcm_out(format=None, **kwargs): + return RokuAudioSink(tv, format=format) + + audiodev.auto.pcm_out = roku_pcm_out From 60fac7f69c5bc65e9acbc2946efeaed849ae2b01 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:26:24 -0500 Subject: [PATCH 4/8] Cast input: a Windows touchscreen and remote reach the app uibcinput reads the report descriptors the sender sends first, so multitouch (report 0x13) and remote-control (0x2A) reports decode by their real layout: each finger becomes a touch where it is, scaled from the descriptor's range to the cast, and a remote's buttons become the K_* keys an app already reads (arrows, OK, Back, Home, play, volume). CastDisplay now offers Windows its whole HID list: offered the keyboard and mouse alone, it sends their descriptors and then nothing. --- lib/examples/cast/cast_display.py | 6 +- lib/examples/cast/uibcinput.py | 249 +++++++++++++++++++++++++++++- tests/test_uibcinput.py | 120 ++++++++++++++ 3 files changed, 365 insertions(+), 10 deletions(-) create mode 100644 tests/test_uibcinput.py diff --git a/lib/examples/cast/cast_display.py b/lib/examples/cast/cast_display.py index 5a0e2b9..45f5317 100644 --- a/lib/examples/cast/cast_display.py +++ b/lib/examples/cast/cast_display.py @@ -149,7 +149,7 @@ def pcm_out(self, format=None, **kwargs): return pycast.CastAudioOut(self.audio, format) # touch_read / keypad_read for a board_config: the laptop's mouse (left - # button held) and keys + # button held), fingers on its touchscreen, and its keys and remote def touch_read(self): return self.uibc.read_points() @@ -181,7 +181,9 @@ def _run(self): self.log("roku power-on:", repr(e)) s = Session(self.sink, name=self.name, log=self.log, on_input=self.uibc.feed) s.session_request = 0 if self.kind == "roku" else None - s.hidc_caps = "Keyboard/USB, Mouse/USB" + # Windows forwards input only when offered its whole list, and + # touch arrives as MultiTouch + s.hidc_caps = "Keyboard/USB, Mouse/USB, MultiTouch/USB, Gesture/USB, RemoteControl/USB" self.sessions += 1 self._session = s diff --git a/lib/examples/cast/uibcinput.py b/lib/examples/cast/uibcinput.py index 33d1a7c..06b5106 100644 --- a/lib/examples/cast/uibcinput.py +++ b/lib/examples/cast/uibcinput.py @@ -1,8 +1,13 @@ -"""The laptop's mouse and keyboard, arriving over the cast's back channel, as a -pydevices input device: a cursor the board integrates from the relative mouse -reports, clicks as touches at the cursor, and HID keyboard reports as the set -of pressed K_* codes. Merged into a board's ``touch_read`` and ``keypad_read`` -so an app cannot tell them from the panel's own touch and keys. +"""The laptop's mouse, keyboard, touchscreen and remote, arriving over the +cast's back channel, as a pydevices input device: a cursor the board +integrates from the relative mouse reports, clicks as touches at the cursor, +fingers on a touchscreen as touches where they are, and HID keyboard and +remote-control reports as the set of pressed K_* codes. Merged into a board's +``touch_read`` and ``keypad_read`` so an app cannot tell them from the panel's +own touch and keys. + +Touch and remote reports are laid out by the report descriptor the sender +sends first, so they are decoded by reading it (``parse_descriptor``). """ import keys @@ -28,6 +33,152 @@ def hid_keycode(usage): return None +# HID usage pages and the usages read here +_DESKTOP = 0x01 +_KEYBOARD = 0x07 +_DIGITIZER = 0x0D +_CONSUMER = 0x0C +_X, _Y = (_DESKTOP, 0x30), (_DESKTOP, 0x31) +_TIP = (_DIGITIZER, 0x42) +_CONTACT_ID = (_DIGITIZER, 0x51) +_CONTACT_COUNT = (_DIGITIZER, 0x54) +_FINGER = (_DIGITIZER, 0x22) + +# a remote control's buttons as the K_* codes an app already reads +_REMOTE_KEYS = { + (_CONSUMER, 0x40): 1073741942, # Menu: K_MENU + (_CONSUMER, 0x41): 13, # Menu Pick: K_RETURN + (_CONSUMER, 0x42): 1073741906, # Menu Up: K_UP + (_CONSUMER, 0x43): 1073741905, # Menu Down: K_DOWN + (_CONSUMER, 0x44): 1073741904, # Menu Left: K_LEFT + (_CONSUMER, 0x45): 1073741903, # Menu Right: K_RIGHT + (_CONSUMER, 0x46): 27, # Menu Escape: K_ESCAPE + (_CONSUMER, 0xB0): 1073742085, # Play: K_AUDIOPLAY + (_CONSUMER, 0xB1): 1073742085, # Pause + (_CONSUMER, 0xCD): 1073742085, # Play/Pause + (_CONSUMER, 0xB5): 1073742082, # Next: K_AUDIONEXT + (_CONSUMER, 0xB6): 1073742083, # Previous: K_AUDIOPREV + (_CONSUMER, 0xB7): 1073742084, # Stop: K_AUDIOSTOP + (_CONSUMER, 0xE2): 1073741951, # Mute: K_MUTE + (_CONSUMER, 0xE9): 1073741952, # Volume Up: K_VOLUMEUP + (_CONSUMER, 0xEA): 1073741953, # Volume Down: K_VOLUMEDOWN + (_CONSUMER, 0x223): 1073742093, # AC Home: K_AC_HOME + (_CONSUMER, 0x224): 1073742094, # AC Back: K_AC_BACK + (_DESKTOP, 0x90): 1073741906, # D-pad Up + (_DESKTOP, 0x91): 1073741905, # D-pad Down + (_DESKTOP, 0x92): 1073741903, # D-pad Right + (_DESKTOP, 0x93): 1073741904, # D-pad Left +} + + +class Field: + """One input field of a HID report: where its bits are and what they mean. + A variable field holds one usage's value; an array field holds the index + of a usage that is on (a pressed button, say), counted from ``usage``.""" + + def __init__(self, page, usage, bit, size, lmin, lmax, coll, array, usage_max=0): + self.page, self.usage, self.bit, self.size = page, usage, bit, size + self.lmin, self.lmax, self.coll, self.array = lmin, lmax, coll, array + self.usage_max = usage_max + + def value(self, data): + byte, shift = self.bit >> 3, self.bit & 7 + n = (shift + self.size + 7) >> 3 + raw = int.from_bytes(bytes(data[byte:byte + n]) + bytes(4), "little") >> shift + v = raw & ((1 << self.size) - 1) + if self.lmin < 0 and v & (1 << (self.size - 1)): + v -= 1 << self.size + return v + + +def _signed(b): + v = int.from_bytes(b, "little") if b else 0 + if b and b[-1] & 0x80: + v -= 1 << (8 * len(b)) + return v + + +def parse_descriptor(desc): + """A HID report descriptor's input reports: {report ID: [Field, ...]}, and + the usage of each collection, by the number a Field's ``coll`` holds. + Only what reading input needs: no outputs, features or designators.""" + reports = {} + colls = [None] # collection number -> its (page, usage) + stack = [0] + g = {"page": 0, "lmin": 0, "lmax": 0, "size": 0, "count": 0, "id": 0} + saved = [] + usages, umin, umax = [], None, None + offsets = {} + i = 0 + while i < len(desc): + b = desc[i] + if b == 0xFE: # long item + i += 3 + (desc[i + 1] if i + 1 < len(desc) else 0) + continue + n = (0, 1, 2, 4)[b & 3] + data = bytes(desc[i + 1:i + 1 + n]) + i += 1 + n + kind, tag = (b >> 2) & 3, b >> 4 + u = int.from_bytes(data, "little") if data else 0 + if kind == 1: # global + if tag == 0: + g["page"] = u + elif tag == 1: + g["lmin"] = _signed(data) + elif tag == 2: + g["lmax"] = _signed(data) if g["lmin"] < 0 else u + elif tag == 7: + g["size"] = u + elif tag == 8: + g["id"] = u + elif tag == 9: + g["count"] = u + elif tag == 10: + saved.append(dict(g)) + elif tag == 11 and saved: + g = saved.pop() + elif kind == 2: # local + full = (u >> 16, u & 0xFFFF) if n == 4 else (g["page"], u) + if tag == 0: + usages.append(full) + elif tag == 1: + umin = full + elif tag == 2: + umax = full + elif kind == 0: # main + if tag == 10: # collection + colls.append(usages[0] if usages else None) + stack.append(len(colls) - 1) + elif tag == 12 and len(stack) > 1: # end collection + stack.pop() + elif tag == 8: # input + rid = g["id"] + bit = offsets.get(rid, 8 if rid else 0) + fields = reports.setdefault(rid, []) + const = u & 1 + variable = u & 2 + for k in range(g["count"]): + if not const: + if variable: + if usages: + page, usage = usages[min(k, len(usages) - 1)] + elif umin is not None: + page, usage = umin[0], umin[1] + k + else: + page, usage = g["page"], 0 + fields.append(Field(page, usage, bit, g["size"], g["lmin"], g["lmax"], + stack[-1], False)) + else: + first = umin if umin is not None else (usages[0] if usages else (g["page"], 0)) + last = umax[1] if umax is not None else first[1] + g["lmax"] - g["lmin"] + fields.append(Field(first[0], first[1], bit, g["size"], g["lmin"], g["lmax"], + stack[-1], True, last)) + bit += g["size"] + offsets[rid] = bit + usages, umin, umax = [], None, None + return reports, colls + + class UibcInput: def __init__(self, width, height, local_touch=None, local_keys=None): self.w = width @@ -40,6 +191,9 @@ def __init__(self, width, height, local_touch=None, local_keys=None): self.local_keys = local_keys self.reports = 0 self.moved = 0 + self.layouts = {} # HID type -> parse_descriptor() of what the sender described + self.fingers = {} # contact id -> (x, y) of each finger down on a touchscreen + self.remote_keys = set() def feed(self, kind, data): """One decoded UIBC report from micecast.Session's on_input.""" @@ -66,19 +220,98 @@ def feed(self, kind, data): pressed.add(0x400000E0 + i) # K_LCTRL .. K_RGUI, the HID modifier usages self.keys = pressed self.reports += 1 + elif kind.endswith("_descriptor"): + self.layouts[kind[4:-11]] = parse_descriptor(data) + elif kind == "hid_multi_touch" and "multi_touch" in self.layouts: + self._touch(data) + elif kind == "hid_remote" and "remote" in self.layouts: + self._remote(data) + + def _layout(self, kind, data): + reports, colls = self.layouts[kind] + if not data: + return None, colls + return reports.get(data[0]) or reports.get(0), colls + + def _touch(self, data): + # One report carries one or more fingers, each in its own Finger + # collection: tip switch, contact id, X and Y. With "hybrid" reports + # (Windows' way) the contact count says how many of them are real. + fields, colls = self._layout("multi_touch", data) + if not fields: + return + groups = {} + order = [] + count = None + for f in fields: + key = (f.page, f.usage) + if key == _CONTACT_COUNT: + count = f.value(data) + elif colls[f.coll] == _FINGER or key in (_TIP, _CONTACT_ID, _X, _Y): + if f.coll not in groups: + groups[f.coll] = {} + order.append(f.coll) + groups[f.coll][key] = f + if count == 0: + # a hybrid report's later part: nothing new + self.reports += 1 + return + for c in order[:count] if count else order: + g = groups[c] + if _X not in g or _Y not in g: + continue + cid = g[_CONTACT_ID].value(data) if _CONTACT_ID in g else c + down = g[_TIP].value(data) if _TIP in g else 1 + if down: + fx, fy = g[_X], g[_Y] + x = (fx.value(data) - fx.lmin) * self.w // (fx.lmax - fx.lmin + 1) + y = (fy.value(data) - fy.lmin) * self.h // (fy.lmax - fy.lmin + 1) + self.fingers[cid] = (min(max(x, 0), self.w - 1), min(max(y, 0), self.h - 1)) + else: + self.fingers.pop(cid, None) + self.reports += 1 + + def _remote(self, data): + fields, _ = self._layout("remote", data) + if not fields: + return + pressed = set() + for f in fields: + v = f.value(data) + if f.array: + if v < f.lmin or v > f.lmax: + continue + usage = f.usage + v - f.lmin + if usage == 0 or usage > f.usage_max: + continue + key = (f.page, usage) + else: + if not v: + continue + key = (f.page, f.usage) + if key[0] == _KEYBOARD: + code = hid_keycode(key[1]) + else: + code = _REMOTE_KEYS.get(key) + if code is not None: + pressed.add(code) + self.remote_keys = pressed + self.reports += 1 def read_points(self): - """A touch_read: the panel's own touches first, else the cursor while the - left button is held.""" + """A touch_read: the panel's own touches first, else fingers on the + laptop's touchscreen, else the cursor while the left button is held.""" pts = self.local_touch() if self.local_touch else () if pts: return pts + if self.fingers: + return tuple(self.fingers.values()) return ((self.cx, self.cy),) if self.buttons & 1 else () def read_keys(self): """A keypad_read: the panel's keys plus the laptop's.""" local = set(self.local_keys() or ()) if self.local_keys else set() - return local | self.keys + return local | self.keys | self.remote_keys def install(self, board_config): """Make the board read this device: touch_read and keypad_read now include it.""" diff --git a/tests/test_uibcinput.py b/tests/test_uibcinput.py new file mode 100644 index 0000000..0f35585 --- /dev/null +++ b/tests/test_uibcinput.py @@ -0,0 +1,120 @@ +"""uibcinput: a touchscreen's and a remote's HID reports, read by their +report descriptors, become touches and keys.""" +import os +import sys +import types +import unittest + +HERE = os.path.dirname(__file__) +sys.path.insert(0, os.path.join(HERE, "..", "lib", "examples", "cast")) +sys.modules.setdefault("keys", types.ModuleType("keys")) # the table in uibcinput stands in + +import uibcinput + +K_UP, K_RETURN, K_AUDIOPLAY, K_VOLUMEUP, K_AC_BACK = 1073741906, 13, 1073742085, 1073741952, 1073742094 + +_FINGER = bytes.fromhex( + "0922" # Usage (Finger) + "a102" # Collection (Logical) + "0942 0947" # Usage (Tip Switch), Usage (Confidence) + "1500 2501 7501 9502 8102" # 0..1, 1 bit x2, Input (Data,Var,Abs) + "9506 8103" # 6 bits padding, Input (Const) + "0951 7508 9501 8102" # Contact Identifier, 8 bits + "0501 26ff7f" # Usage Page (Desktop), Logical Max 32767 (lmin still 0) + "7510 9501 0930 8102" # X, 16 bits + "0931 8102" # Y, 16 bits + "050d" # back to Usage Page (Digitizer) + "c0".replace(" ", "")) +# Windows' kind: report 0x13, two fingers per report, then the contact count +MULTITOUCH = bytes.fromhex("050d 0904 a101 8513".replace(" ", "")) + _FINGER + _FINGER + bytes.fromhex( + "0954 2508 7508 9501 8102 c0".replace(" ", "")) +# a consumer-control remote: report 0x2A, one 16-bit array slot of usages 0..0x3FF +REMOTE = bytes.fromhex("050c 0901 a101 852a 1500 26ff03 1900 2aff03 7510 9501 8100 c0".replace(" ", "")) + + +def touch(*fingers, count=None): + body = bytearray([0x13]) + for tip, cid, x, y in fingers: + body += bytes([tip | (tip << 1), cid]) + x.to_bytes(2, "little") + y.to_bytes(2, "little") + while len(body) < 1 + 2 * 6: + body += bytes(6) + body.append(len(fingers) if count is None else count) + return bytes(body) + + +class DescriptorTest(unittest.TestCase): + def test_multitouch_layout(self): + reports, colls = uibcinput.parse_descriptor(MULTITOUCH) + fields = reports[0x13] + xs = [f for f in fields if (f.page, f.usage) == (1, 0x30)] + self.assertEqual([f.bit for f in xs], [8 + 16, 8 + 64]) + self.assertEqual((xs[0].size, xs[0].lmin, xs[0].lmax), (16, 0, 32767)) + self.assertEqual(colls[xs[0].coll], (0x0D, 0x22)) + count = [f for f in fields if (f.page, f.usage) == (0x0D, 0x54)][0] + self.assertEqual(count.bit, 8 + 96) + + def test_remote_layout(self): + reports, _ = uibcinput.parse_descriptor(REMOTE) + f = reports[0x2A][0] + self.assertTrue(f.array) + self.assertEqual((f.page, f.usage, f.usage_max, f.size), (0x0C, 0, 0x3FF, 16)) + + +class TouchTest(unittest.TestCase): + def setUp(self): + self.u = uibcinput.UibcInput(720, 720) + self.u.feed("hid_multi_touch_descriptor", MULTITOUCH) + + def test_a_finger_lands_where_it_touched(self): + self.u.feed("hid_multi_touch", touch((1, 7, 16384, 8192))) + self.assertEqual(self.u.read_points(), ((360, 180),)) + self.u.feed("hid_multi_touch", touch((0, 7, 16384, 8192))) + self.assertEqual(self.u.read_points(), ()) + + def test_two_fingers_and_a_hybrid_report(self): + self.u.feed("hid_multi_touch", touch((1, 1, 0, 0), (1, 2, 32767, 32767))) + self.assertEqual(sorted(self.u.read_points()), [(0, 0), (719, 719)]) + # a second report for the same frame, count 0: nothing changes + self.u.feed("hid_multi_touch", touch((0, 1, 0, 0), count=0)) + self.assertEqual(len(self.u.read_points()), 2) + self.u.feed("hid_multi_touch", touch((0, 1, 0, 0))) + self.assertEqual(self.u.read_points(), ((719, 719),)) + + def test_the_panel_comes_first(self): + self.u.local_touch = lambda: ((5, 5),) + self.u.feed("hid_multi_touch", touch((1, 1, 100, 100))) + self.assertEqual(self.u.read_points(), ((5, 5),)) + + def test_no_descriptor_no_touch(self): + u = uibcinput.UibcInput(720, 720) + u.feed("hid_multi_touch", touch((1, 1, 100, 100))) + self.assertEqual(u.read_points(), ()) + + +class RemoteTest(unittest.TestCase): + def setUp(self): + self.u = uibcinput.UibcInput(720, 720) + self.u.feed("hid_remote_descriptor", REMOTE) + + def press(self, usage): + self.u.feed("hid_remote", bytes([0x2A]) + usage.to_bytes(2, "little")) + return self.u.read_keys() + + def test_buttons_are_keys(self): + self.assertEqual(self.press(0x42), {K_UP}) + self.assertEqual(self.press(0x41), {K_RETURN}) + self.assertEqual(self.press(0xCD), {K_AUDIOPLAY}) + self.assertEqual(self.press(0xE9), {K_VOLUMEUP}) + self.assertEqual(self.press(0x224), {K_AC_BACK}) + self.assertEqual(self.press(0), set()) # released + + def test_unknown_button_is_ignored(self): + self.assertEqual(self.press(0x3FE), set()) + + def test_keyboard_and_remote_together(self): + self.u.feed("hid_keyboard", bytes([0x29, 0, 0, 0x04, 0, 0, 0, 0, 0])) + self.assertEqual(self.press(0x42), {97, K_UP}) + + +if __name__ == "__main__": + unittest.main() From c73a4a07c879caf20ac9e011256ed5979281d2d9 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:26:44 -0500 Subject: [PATCH 5/8] Guide: why the Roku remote can't reach a Miracast cast --- docs/guides/cast-to-a-tv.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/guides/cast-to-a-tv.md b/docs/guides/cast-to-a-tv.md index 86e4a8f..3068d6d 100644 --- a/docs/guides/cast-to-a-tv.md +++ b/docs/guides/cast-to-a-tv.md @@ -190,4 +190,8 @@ the [archived pydevices-examples repo](https://github.com/PyDevices/pydevices-ex ## The Roku remote over Miracast -The Roku's remote doesn't reach the app over Miracast. It is tracked in [#168](https://github.com/PyDevices/pydevices-examples/issues/168) and listed on the [roadmap](https://github.com/PyDevices/gallery/blob/main/ROADMAP.md). +The Roku's remote doesn't reach the app over Miracast. The Roku offers no +input back channel (its reply leaves out `wfd_uibc_capability`), and its +network API has no way to report a button press; while it mirrors, the +remote's arrows, OK, Back and the rest reach neither the source nor any +other channel. For the remote, use the Companion channel. It is tracked in [#168](https://github.com/PyDevices/pydevices-examples/issues/168) and listed on the [roadmap](https://github.com/PyDevices/gallery/blob/main/ROADMAP.md). From 1aa0e0e8dbc4cd62c018fb239b3c432524c52ea1 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:27:55 -0500 Subject: [PATCH 6/8] CastDisplay: a board without the audio pump casts silently --- lib/examples/cast/cast_display.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/examples/cast/cast_display.py b/lib/examples/cast/cast_display.py index 45f5317..2234380 100644 --- a/lib/examples/cast/cast_display.py +++ b/lib/examples/cast/cast_display.py @@ -83,10 +83,13 @@ def __init__(self, sink, width=720, height=720, kind="windows", canvas=None, self.log = log or (lambda *a: None) self.audio = None # the P4's TapFeed, or the desktop's pycast.CastAudio if castfast is not None: - self.cast = castfast.make_caster(width, height, canvas=canvas or (width, height), - fps=fps, bitrate=bitrate, audio=bool(audio)) if audio: - self.audio = castfast.TapFeed(rate=audio_format[0], channels=audio_format[1], log=self.log) + try: + self.audio = castfast.TapFeed(rate=audio_format[0], channels=audio_format[1], log=self.log) + except Exception as e: # firmware without the audio pump: a silent cast + self.log("cast sound off:", repr(e)) + self.cast = castfast.make_caster(width, height, canvas=canvas or (width, height), + fps=fps, bitrate=bitrate, audio=self.audio is not None) else: if audio: self.audio = pycast.CastAudio() From eaecd89fb80cd894c7959a5bdd0b097a7031b781 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:04:08 -0500 Subject: [PATCH 7/8] uibcinput tests: the descriptors Windows' Wireless Display really sends Five fingers per 0x13 report with X and Y 0..32767, a remote of one 16-bit consumer usage 0..0xFF, and the short 0x12 report it sends first. --- tests/data/windows_uibc_descriptors.txt | 6 +++ tests/test_uibcinput.py | 69 +++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/data/windows_uibc_descriptors.txt diff --git a/tests/data/windows_uibc_descriptors.txt b/tests/data/windows_uibc_descriptors.txt new file mode 100644 index 0000000..a6632c4 --- /dev/null +++ b/tests/data/windows_uibc_descriptors.txt @@ -0,0 +1,6 @@ +hid_mouse_descriptor 05010902a10185280901a1000509190129081500250195087501810205010930093109380a38021581257f750895048106c0c0 +hid_keyboard_descriptor 05010906a1018529050719e029e71500250175019508810295017508810395057501050819012905910295017503910395067508150025650507190029658100c0 +hid_remote_descriptor 050c0901a101852a19002aff00150026ff00950175108100c0 +hid_multi_touch_descriptor 050d0904a1018513050d095495017508150025638102550c66011047ffff000027ffff00007510950109568102050d0922a102150025013500450055006500750195010942810209478102950481030600ff09109501750281027508050d095126630081027510550e651127ff7f00004640060948810209498102050109308102468403050109318102050d651447a08c000027a08c0000093f8102c0050d0922a102150025013500450055006500750195010942810209478102950481030600ff09109501750281027508050d095126630081027510550e651127ff7f00004640060948810209498102050109308102468403050109318102050d651447a08c000027a08c0000093f8102c0050d0922a102150025013500450055006500750195010942810209478102950481030600ff09109501750281027508050d095126630081027510550e651127ff7f00004640060948810209498102050109308102468403050109318102050d651447a08c000027a08c0000093f8102c0050d0922a102150025013500450055006500750195010942810209478102950481030600ff09109501750281027508050d095126630081027510550e651127ff7f00004640060948810209498102050109308102468403050109318102050d651447a08c000027a08c0000093f8102c0050d0922a102150025013500450055006500750195010942810209478102950481030600ff09109501750281027508050d095126630081027510550e651127ff7f00004640060948810209498102050109308102468403050109318102050d651447a08c000027a08c0000093f8102c0050d85120955950175101500266400b102c0 +hid_multi_touch 120a00 +hid_gesture_descriptor 050d0902a1018514050d0920a10009420944093c094509321500250175019505810295038103050127ff7f000075109501550e6533093035004640068142093146840381426500050d093081020600ff09118102091281027520050609228102c0c0050d0902a1018515050d0920a10009420944093c094509321500250175019505810295038103050127ff7f000075109501550e6533093035004640068142093146840381426500050d093081020600ff09118102091281027520050609228102c0c0 diff --git a/tests/test_uibcinput.py b/tests/test_uibcinput.py index 0f35585..e8cd28a 100644 --- a/tests/test_uibcinput.py +++ b/tests/test_uibcinput.py @@ -116,5 +116,74 @@ def test_keyboard_and_remote_together(self): self.assertEqual(self.press(0x42), {97, K_UP}) +def _windows(): + with open(os.path.join(HERE, "data", "windows_uibc_descriptors.txt")) as f: + return {k: bytes.fromhex(v) for k, v in (line.split() for line in f if line.strip())} + + +class WindowsTest(unittest.TestCase): + """The descriptors Windows 11's Wireless Display sent a P4 on 2026-10-29, + from a PC with a touchscreen: five fingers per 0x13 report, X and Y 0..32767, + and a remote of one 16-bit consumer usage 0..0xFF.""" + + def setUp(self): + self.d = _windows() + self.u = uibcinput.UibcInput(720, 720) + for kind in ("hid_mouse_descriptor", "hid_keyboard_descriptor", "hid_remote_descriptor", + "hid_multi_touch_descriptor", "hid_gesture_descriptor"): + self.u.feed(kind, self.d[kind]) + self.fields = uibcinput.parse_descriptor(self.d["hid_multi_touch_descriptor"])[0][0x13] + + def report(self, fingers): + """A 0x13 report built from the descriptor's own fields: fingers are + (id, x, y) or (id, x, y, tip); a lifted finger is reported once with + tip 0, and counted.""" + bits = max(f.bit + f.size for f in self.fields) + value = 0x13 + slots = sorted({f.coll for f in self.fields if (f.page, f.usage) == (0x0D, 0x42)}) + for f in self.fields: + v = 0 + if (f.page, f.usage) == (0x0D, 0x54): + v = len(fingers) + elif f.coll in slots and slots.index(f.coll) < len(fingers): + cid, x, y, tip = (tuple(fingers[slots.index(f.coll)]) + (1,))[:4] + v = {(0x0D, 0x42): tip, (0x0D, 0x47): 1, (0x0D, 0x51): cid, + (0x01, 0x30): x, (0x01, 0x31): y}.get((f.page, f.usage), 0) + value |= v << f.bit + return value.to_bytes(bits // 8, "little") + + def test_five_fingers_a_report(self): + slots = {f.coll for f in self.fields if (f.page, f.usage) == (0x0D, 0x42)} + self.assertEqual(len(slots), 5) + self.assertEqual(max(f.bit + f.size for f in self.fields), 512) + + def test_a_finger_lands_where_it_touched(self): + self.u.feed("hid_multi_touch", self.report([(3, 16384, 8192)])) + self.assertEqual(self.u.read_points(), ((360, 180),)) + self.u.feed("hid_multi_touch", self.report([(3, 16384, 8192, 0)])) + self.assertEqual(self.u.read_points(), ()) + + def test_two_fingers(self): + self.u.feed("hid_multi_touch", self.report([(0, 0, 0), (1, 32767, 32767)])) + self.assertEqual(sorted(self.u.read_points()), [(0, 0), (719, 719)]) + + def test_the_short_report_it_sends_first(self): + # Windows sent 12 0a 00 right after the descriptors: an ID this + # descriptor doesn't declare, so it changes nothing + self.u.feed("hid_multi_touch", self.d["hid_multi_touch"]) + self.assertEqual(self.u.read_points(), ()) + + def test_remote(self): + reports = uibcinput.parse_descriptor(self.d["hid_remote_descriptor"])[0] + f = reports[0x2A][0] + self.assertEqual((f.page, f.usage, f.usage_max, f.size, f.array), (0x0C, 0, 0xFF, 16, True)) + self.u.feed("hid_remote", bytes([0x2A, 0x42, 0])) + self.assertEqual(self.u.read_keys(), {K_UP}) + self.u.feed("hid_remote", bytes([0x2A, 0xCD, 0])) + self.assertEqual(self.u.read_keys(), {K_AUDIOPLAY}) + self.u.feed("hid_remote", bytes([0x2A, 0, 0])) + self.assertEqual(self.u.read_keys(), set()) + + if __name__ == "__main__": unittest.main() From ee0081101b06641fa0f4148c530f2cc3ade8006d Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:44:49 -0500 Subject: [PATCH 8/8] P4 cast sound: the tap is stereo, and RokuAudioSink keeps up on a board The pump drives the I2S wire in stereo even for a mono app, so the cast read the tap as mono and played an octave low; CastDisplay and drum_cast now take it as 24 kHz stereo. RokuAudioSink imported time inside functions, which on a board searches the filesystem each call (65 ms on the P4) and held its writes to a tenth of the sound's rate; its server thread also spun in non-blocking accepts. The P4 row of the sound table, measured on a Roku TV. --- docs/reference/cast-runtimes.md | 8 ++++++++ lib/examples/cast/cast_display.py | 10 ++++++---- lib/examples/cast/castfast.py | 9 +++++---- lib/examples/cast/drum_cast.py | 5 +++-- lib/utils/roku_companion.py | 26 +++++++++----------------- tests/test_roku_audio_sink.py | 17 +++++++++++++++++ 6 files changed, 48 insertions(+), 27 deletions(-) diff --git a/docs/reference/cast-runtimes.md b/docs/reference/cast-runtimes.md index f963695..d9d665c 100644 --- a/docs/reference/cast-runtimes.md +++ b/docs/reference/cast-runtimes.md @@ -33,6 +33,7 @@ speech the recording was transcribed back from and beeps it was timed by. | `python.exe` (Windows) | plays, no drift, no gaps | plays, 5.4 s behind | | `micropython` (unix) | no H.264 | plays, 3.5 s behind | | `micropython.exe` (Windows) | no H.264 | plays, 4.8 to 5.2 s behind | +| ESP32-P4 (the panel) | plays, no drift, no gaps | plays, 4.3 s behind | Over Miracast the sound rides the picture's clock, so it is in step with the picture. Over the Companion channel the TV plays sound or frames, not both, @@ -41,6 +42,13 @@ the last seconds stay in the TV until the stream closes. Those figures include the 2 s the sink writes ahead, and leave out the microphone's own start-up, which makes them a little long. +On the P4, the cast takes its sound from the audio pump's output, which is +stereo on the I2S wire even when the app opened a mono stream: a 24 kHz mono +app is `audio_format=(24000, 2)`, the default. Read as mono, the same sound +played an octave low. The ESP32-P4 Wi-Fi 6 dev kit can't cast sound: its +Wi-Fi barely sends while its speaker runs, and the TV gives up on the +session. + **Miracast to Windows** is testris only, with glitches from Wi-Fi loss (see [Miracast to a Windows laptop](../guides/cast-to-a-tv.md#miracast-to-a-windows-laptop)). From CPython it needs a second PC as the receiver: Windows won't take a cast from itself. diff --git a/lib/examples/cast/cast_display.py b/lib/examples/cast/cast_display.py index 2234380..0103893 100644 --- a/lib/examples/cast/cast_display.py +++ b/lib/examples/cast/cast_display.py @@ -58,18 +58,20 @@ class CastDisplay(FBDisplay): name: what the sink lists the cast as. log: where session messages go (default: nowhere). audio: None for a silent cast. True carries the app's sound: on the - P4 what the audio pump plays (24 kHz mono, the panel's own - format, unless ``audio_format`` says otherwise), on a desktop what + P4 what the audio pump plays (24 kHz, unless ``audio_format`` says + otherwise), on a desktop what the app writes to ``pcm_out()``. On a desktop, a file, URL or list of ffmpeg input arguments casts that instead. - audio_format: (rate, channels) of the P4's pump, for audio=True. + audio_format: (rate, channels) of what the P4's pump drives, for + audio=True. The pump drives the I2S wire in stereo even when the + app opened a mono stream, so a 24 kHz mono app is (24000, 2). audio_loop: play a desktop's audio file over and over. A file starts from its beginning with each session, once the sink is listening. """ def __init__(self, sink, width=720, height=720, kind="windows", canvas=None, fps=30, bitrate=3_000_000, name="PyDevices P4", log=None, - audio=None, audio_format=(24000, 1), audio_loop=False): + audio=None, audio_format=(24000, 2), audio_loop=False): if kind not in ("windows", "roku"): raise ValueError("kind is 'windows' or 'roku'") self._buf = bytearray(width * height * 2) diff --git a/lib/examples/cast/castfast.py b/lib/examples/cast/castfast.py index 50b52be..58cf732 100644 --- a/lib/examples/cast/castfast.py +++ b/lib/examples/cast/castfast.py @@ -178,10 +178,11 @@ class TapFeed: here; each block is stamped when read (at most one poll late). The cast carries 48 kHz stereo (the only LPCM a sink takes), and the - pump runs at whatever the app opened: the drum machine's low-latency - output on the P4 is 24 kHz MONO. Pass the pump's ``rate`` and - ``channels``; mono is spread to both sides and 24 kHz is doubled with a - midpoint between samples. ``in_bytes`` counts what the tap produced, so + pump runs at the rate the app opened: the drum machine's low-latency + output on the P4 is 24 kHz. The tap holds what goes to the I2S wire, + which is stereo even for a mono app (measured on the P4: each sample + twice). Pass the tap's ``rate`` and ``channels``; mono is spread to both + sides and 24 kHz is doubled with a midpoint between samples. ``in_bytes`` counts what the tap produced, so a wrong format shows in the log as a byte rate that is not rate*ch*2. """ diff --git a/lib/examples/cast/drum_cast.py b/lib/examples/cast/drum_cast.py index d4bb4ae..03aae11 100644 --- a/lib/examples/cast/drum_cast.py +++ b/lib/examples/cast/drum_cast.py @@ -25,8 +25,9 @@ w = wifi_up() # The drum machine opens board_peripherals.audio_out(latency="low"), which on -# the P4 is 24 kHz mono; the cast carries 48 kHz stereo and converts. -feed = castfast.TapFeed(rate=24000, channels=1, log=log) +# the P4 is 24 kHz mono, and the pump drives the I2S wire with it in stereo: +# that is what the tap holds. The cast carries 48 kHz stereo and converts. +feed = castfast.TapFeed(rate=24000, channels=2, log=log) if MODE == "tv": from roku_cast import RokuScreen diff --git a/lib/utils/roku_companion.py b/lib/utils/roku_companion.py index 389a8e8..4fae7aa 100644 --- a/lib/utils/roku_companion.py +++ b/lib/utils/roku_companion.py @@ -48,6 +48,9 @@ """ import socket +# once, here: on a board, an import inside a function searches the filesystem +# each call (65 ms on the P4), which slowed every sound write by a factor of ten +import time # Frames go to the TV as PNG through pngio: MicroPython's built-in module, or # pydevices-desktop's pngio.py over Pillow on CPython. Compressed, a typical @@ -281,8 +284,6 @@ def _ecp_post(host, path, timeout=5.0): def _now(): """Seconds on a clock that never steps backwards (WSL resyncs its wall clock).""" - import time - try: return time.monotonic() except AttributeError: @@ -355,8 +356,6 @@ def _session_url(url): Roku caches images by URL: without the token a new session's ``frame.png?t=1`` is the old session's cached picture, and the TV replays earlier runs instead of fetching new frames.""" - import time - try: token = time.ticks_ms() except AttributeError: @@ -493,8 +492,6 @@ def power_on(self, wait=15): """Wake the TV (ECP PowerOn; harmless when it is already on) and wait up to *wait* seconds for it to say it is on. A channel launched before then lands on the Home screen instead. Returns whether it is on.""" - import time - _ecp_post(self.host, "/keypress/PowerOn", self.timeout) for _ in range(wait * 2): if "PowerOn" in _ecp_get( @@ -727,8 +724,6 @@ def _wait_for_new_frame(self, sent_version, limit=_HOLD_SECONDS): exists. After `limit` seconds the current frame goes anyway, so a still screen keeps its connection alive. """ - import time - deadline = _now() + limit while self._running and self._version == sent_version and _now() < deadline: time.sleep(0.002) @@ -1190,9 +1185,9 @@ class Format: except ImportError: self._threaded = False - def poll(self): + def poll(self, timeout=0.0): try: - self._sock.settimeout(0.0) + self._sock.settimeout(timeout) conn, addr = self._sock.accept() except OSError: return @@ -1242,12 +1237,11 @@ def poll(self): pass def _server_loop(self): - try: - self._sock.settimeout(0.5) - except OSError: - pass + # wait in accept: a loop of non-blocking accepts spins, and on a board + # (one interpreter lock) it starved the writer to a fifth of the + # sound's rate while self._running: - self.poll() + self.poll(0.5) def open(self): pass @@ -1273,8 +1267,6 @@ def write(self, buf): # Paced by the clock, as a sound card would pace it: the TV reads as # fast as it is sent, so without this an app's sound loop runs many # times faster than its sound plays. - import time - bytes_per_sec = self.format.rate * self.format.channels * (self.format.bits // 8) now = _now() if self._t0 is None or self._t0 + self._sent / bytes_per_sec < now - 0.2: diff --git a/tests/test_roku_audio_sink.py b/tests/test_roku_audio_sink.py index cd3f188..6e4cd92 100644 --- a/tests/test_roku_audio_sink.py +++ b/tests/test_roku_audio_sink.py @@ -62,6 +62,23 @@ def test_writes_are_paced_by_the_clock(self): self.assertGreater(took, 0.25) # an array counts in bytes, not items self.assertLess(took, 0.6) + def test_the_server_thread_waits_instead_of_spinning(self): + accepts = [] + real = self.sink._sock + + class Counting: + def __getattr__(self, name): + return getattr(real, name) + + def accept(self): + accepts.append(1) + return real.accept() + + self.sink._sock = Counting() + time.sleep(1.2) + self.sink._sock = real + self.assertLess(len(accepts), 10) # one per 0.5 s wait, not thousands + if __name__ == "__main__": unittest.main()