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/docs/guides/cast-to-a-tv.md b/docs/guides/cast-to-a-tv.md
index 0bd753f..3068d6d 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)
@@ -179,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).
diff --git a/docs/reference/cast-runtimes.md b/docs/reference/cast-runtimes.md
index 3cd96d3..d9d665c 100644
--- a/docs/reference/cast-runtimes.md
+++ b/docs/reference/cast-runtimes.md
@@ -24,6 +24,31 @@ 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 |
+| 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,
+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.
+
+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 6121bb9..0103893 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,21 @@ 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, 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 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):
+ fps=30, bitrate=3_000_000, name="PyDevices P4", log=None,
+ 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)
@@ -65,11 +83,22 @@ 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:
+ if audio:
+ 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)
+ fps=fps, bitrate=bitrate, audio=self.audio is not None)
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,11 +138,23 @@ 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
+ # button held), fingers on its touchscreen, and its keys and remote
def touch_read(self):
return self.uibc.read_points()
@@ -145,7 +186,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
@@ -154,7 +197,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 +221,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/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/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/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/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/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
diff --git a/lib/utils/roku_companion.py b/lib/utils/roku_companion.py
index f56cb9a..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)
@@ -1129,7 +1124,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 +1159,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
@@ -1181,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
@@ -1196,7 +1200,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 +1209,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/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/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_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()
diff --git a/tests/test_roku_audio_sink.py b/tests/test_roku_audio_sink.py
new file mode 100644
index 0000000..6e4cd92
--- /dev/null
+++ b/tests/test_roku_audio_sink.py
@@ -0,0 +1,84 @@
+"""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("