From b5cfcb64cf31315094ec101fa7b6c060e4bb6b1a Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:07:26 -0500 Subject: [PATCH] Cast: watch a medialink camera on a TV, with its sound cast/medialink_tv.py finds a medialink camera by name and casts its pictures and sound over Miracast to a Roku TV or Windows Wireless Display. CastDisplay.pcm_out() now works on the ESP32-P4 too: it is the board's speaker through the audio pump (castfast.pump_out), which the cast hears. boarddev.pcm_out() there is plain I2S, so its sound never reached the cast. CastDisplay.blit_rect copies whole rows as one run instead of framebuf's pixel-by-pixel blit: 11 ms for 640x480 on the P4 instead of 60. --- gallery.toml | 1 + lib/examples/cast/README.md | 1 + lib/examples/cast/cast_display.py | 26 +++++-- lib/examples/cast/castfast.py | 39 ++++++++++ lib/examples/cast/medialink_tv.py | 124 ++++++++++++++++++++++++++++++ packages/cast.json | 4 + packages/examples.json | 4 + 7 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 lib/examples/cast/medialink_tv.py diff --git a/gallery.toml b/gallery.toml index 6b180c4..4e486ec 100644 --- a/gallery.toml +++ b/gallery.toml @@ -37,6 +37,7 @@ "https://raw.githubusercontent.com/PyDevices/gallery/main/lib/examples/cast/drum_cast.py" = "/examples/cast/drum_cast.py" "https://raw.githubusercontent.com/PyDevices/gallery/main/lib/examples/cast/headless_cast.py" = "/examples/cast/headless_cast.py" "https://raw.githubusercontent.com/PyDevices/gallery/main/lib/examples/cast/laptop_input.py" = "/examples/cast/laptop_input.py" +"https://raw.githubusercontent.com/PyDevices/gallery/main/lib/examples/cast/medialink_tv.py" = "/examples/cast/medialink_tv.py" "https://raw.githubusercontent.com/PyDevices/gallery/main/lib/examples/cast/melody.py" = "/examples/cast/melody.py" "https://raw.githubusercontent.com/PyDevices/gallery/main/lib/examples/cast/micecast.py" = "/examples/cast/micecast.py" "https://raw.githubusercontent.com/PyDevices/gallery/main/lib/examples/cast/pc/rtp_capture.py" = "/examples/cast/pc/rtp_capture.py" diff --git a/lib/examples/cast/README.md b/lib/examples/cast/README.md index c72dfc5..61ea328 100644 --- a/lib/examples/cast/README.md +++ b/lib/examples/cast/README.md @@ -35,6 +35,7 @@ address at the top; set it first. | `import cast.tv_cast` | The panel on the TV for 60 s with a melody from the P4's speaker and the TV together. The TV is turned on first. | | `import cast.laptop_input` | The panel in the laptop's Wireless Display app. Move the mouse, click, type: the P4 draws the cursor, a dot per click and the keys. | | `import cast.drum_cast` | The drum machine, cast with its sound. Press PLAY on the panel. | +| `import cast.medialink_tv` | A medialink camera's pictures and sound on the TV or the laptop: the board finds the camera by name and casts what it sends. Run pydevices' surveillance camera example on the camera side. | A Roku needs **Screen mirroring** on in its settings. A Windows laptop needs the Wireless Display app open (Settings > System > Projecting to this PC). diff --git a/lib/examples/cast/cast_display.py b/lib/examples/cast/cast_display.py index 0103893..06ace7a 100644 --- a/lib/examples/cast/cast_display.py +++ b/lib/examples/cast/cast_display.py @@ -123,6 +123,11 @@ def fill_rect(self, x, y, w, h, c): return (x, y, w, h) def blit_rect(self, buf, x, y, w, h): + if x == 0 and w == self.width and 0 <= y and y + h <= self.height and len(buf) == w * h * 2: + # whole rows are one run of the buffer: a copy, where framebuf's + # blit goes pixel by pixel (60 ms for 640x480 on the P4, 11 ms this way) + memoryview(self._buf)[y * w * 2:(y + h) * w * 2] = buf + return (x, y, w, h) try: src = framebuf.FrameBuffer(buf, w, h, framebuf.RGB565) except (TypeError, ValueError): # a read-only buffer: FBDisplay's copy @@ -145,13 +150,20 @@ def stats(self): 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) + def pcm_out(self, format=None, speaker_volume=85, **kwargs): + """A speaker on the cast: an audiodev PCMOutput whose sound goes out + with the picture. Needs audio=True. On a desktop it takes any 16-bit + rate, mono or stereo. On the P4 it is the board's own speaker through + the audio pump (at ``speaker_volume``, 0 to 100; the cast's level is + the TV's), at the rate ``audio_format`` names, mono or stereo: + boarddev.pcm_out() there is plain I2S, which the cast never hears.""" + if self.audio is None: + raise RuntimeError("pcm_out needs CastDisplay(..., audio=True)") + if pycast is not None: + return pycast.CastAudioOut(self.audio, format) + if format is None or format.rate != self.audio.rate: + raise ValueError("the P4's cast speaker runs at audio_format's rate, %d Hz" % self.audio.rate) + return castfast.pump_out(format, volume=speaker_volume, log=self.log) # touch_read / keypad_read for a board_config: the laptop's mouse (left # button held), fingers on its touchscreen, and its keys and remote diff --git a/lib/examples/cast/castfast.py b/lib/examples/cast/castfast.py index 58cf732..f40e1e4 100644 --- a/lib/examples/cast/castfast.py +++ b/lib/examples/cast/castfast.py @@ -50,6 +50,45 @@ def pump_stream(fmt=None, volume=85, capacity=32, log=print): return stream +def pump_out(fmt, volume=85, log=print): + """An audiodev PCMOutput on the board's speaker through the audio pump, + so what it plays is on the cast too (TapFeed reads the pump's output). + boarddev.pcm_out() is machine.I2S, which the tap never sees.""" + from audiodev import PCMOutput + + class PumpOut(PCMOutput): + def __init__(self): + super().__init__(fmt) + self.stream = pump_stream(fmt, volume=volume, log=log) + self.capacity = self.stream.space() # empty: the whole ring + self.frame = fmt.channels * 2 + + def _write(self, buf): + # A sound card's pace: wait (briefly) for room, take what fits. + for _ in range(50): + room = self.stream.space() + if room >= self.frame: + break + time.sleep_ms(2) + n = min(len(buf), room) + n -= n % self.frame + if n: + self.stream.write(buf[:n]) + return n + + def queued_size(self): + return max(0, self.capacity - self.stream.space()) + + def is_active(self): + return self.queued_size() > 0 + + def deinit(self): + self.close() + self.stream.deinit() + + return PumpOut() + + class PumpFeed: """The one producer for both listeners: the P4's speaker (the pump) and the cast. diff --git a/lib/examples/cast/medialink_tv.py b/lib/examples/cast/medialink_tv.py new file mode 100644 index 0000000..d073006 --- /dev/null +++ b/lib/examples/cast/medialink_tv.py @@ -0,0 +1,124 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Watch a medialink camera on a TV, with its sound. + +This finds a medialink camera on the network by name and casts what it +sees and hears to a TV over Miracast: a Roku TV with Screen mirroring on, +or a Windows PC's Wireless Display app. The TV is the screen and the +speaker, so the board running this needs no display of its own. Every few +seconds it prints how many pictures it showed and how the sound is going. + +The camera side is pydevices' medialink surveillance camera example, run +with CAMERA = True on a board with a camera, or on a computer with a +folder of JPEG files. Set its RATE to 24000 to match this one: on a board +the cast carries what the board's speaker plays, and reads it at 24 or +48 kHz. Set its FPS to 5 as well. Sound comes first: an ESP32-P4 decodes +and shows a 640x480 picture in about 80 ms, so it keeps up with 5 a second +and still has time for the sound, where at 10 a second it shows about 6 +and drops packets. The cast itself runs at FPS, 10 frames a second, since +it sends a frame only when the picture changes; capping it at 30, 15 or +10 made no difference to the sound. + +Needs: on a board, an ESP32-P4 with firmware that carries castif, h264enc +and the audio pump, and Wi-Fi; on a computer, CPython with ffmpeg on the +path. Both need pydevices' medialink and audiodev, and jpegio (built into +the firmware, or Pillow on CPython). The Roku TV needs Screen mirroring +on; Windows needs its Wireless Display app open, on another computer than +the one running this. + +Run it on the board, beside the cast example's files: + + import cast.medialink_tv + +or on a computer: + + python lib/examples/cast/medialink_tv.py + +Tested on: CPython 3.12 on Linux and Windows to a Roku TV, and a Waveshare +ESP32-P4 panel on MicroPython 1.30 preview to a Roku TV and to Windows +Wireless Display, with a computer's folder of JPEG files as the camera. +""" + +import sys + +import medialink +import multimer +from audiodev import AudioFormat +from cast_display import CastDisplay + +# Change these to match your setup. +TV = "roku:192.168.1.129" # "roku:ADDRESS" for a Roku TV, "windows:ADDRESS" for Wireless Display +CAMERA_NAME = "porch-camera" # the camera side's name on the network +CAMERA_ADDRESS = None # or ("192.168.1.20", PORT) to skip the search +PORT = 47800 +WIDTH, HEIGHT = 640, 480 # the cast's picture; the TV scales it to fit +FPS = 10 # the cast's frames a second, at most; it sends only when a picture changes +RATE = 24000 # sound samples a second: 24000 or 48000, the same as the camera's +SPEAKER_VOLUME = 0 # a board's own speaker, 0 to 100; the TV has its own volume + +# Start the cast. It joins the TV in the background and rejoins by itself +# if the TV goes away. +kind, address = TV.split(":", 1) +if sys.implementation.name != "cpython": + from _common import wifi_up + + wifi_up() +display = CastDisplay(address, WIDTH, HEIGHT, kind=kind, name="Porch camera", fps=FPS, + audio=True, audio_format=(RATE, 2)) + +# The speaker whose sound goes out with the picture. On a board it is the +# board's own speaker too, at SPEAKER_VOLUME. +audio = AudioFormat(RATE, 1, 16) +speaker = display.pcm_out(audio, speaker_volume=SPEAKER_VOLUME) + +# Open the link, with pictures and sound coming in. +link = medialink.Link(medialink.UDPTransport(PORT), retries=2) +video = medialink.Video(link) +screen = medialink.Screen(display) +intercom = medialink.Intercom(link, None, speaker) +finder = medialink.MDNS() +print("Casting to %s, looking for %r" % (TV, CAMERA_NAME)) + + +def look_for_camera(): + """Find the camera by name and say hello to it.""" + found = [(CAMERA_NAME, CAMERA_ADDRESS)] if CAMERA_ADDRESS else finder.find(CAMERA_NAME, timeout=1) + for _name, where in found: + try: + link.connect(where) + print("Watching %r at %s" % (CAMERA_NAME, where)) + except medialink.PeerUnreachable: + pass + + +# Show each picture on the TV and play the sound, until stopped. +looked = reported = multimer.ticks_ms() - 3000 +counted = 0 +try: + while True: + now = multimer.ticks_ms() + if not link.alive and multimer.ticks_diff(now, looked) > 3000: + look_for_camera() + looked = multimer.ticks_ms() + frame = video.receive() + if frame is not None: + screen.show(frame.payload) + display.show() + intercom.service() + + # Every five seconds, say how it is going. + if multimer.ticks_diff(now, reported) >= 5000: + total = video.stats["received"] + rate = (total - counted) * 1000 / multimer.ticks_diff(now, reported) + cast = display.stats() + print("%d pictures shown, %.1f a second; sound %s ms late, %d underruns; cast %.0f fps, %d sessions" + % (total, rate, intercom.stats["latency_ms"], intercom.stats["underruns"], cast["fps"], + cast["sessions"])) + counted, reported = total, now + multimer.sleep_ms(1) +finally: + intercom.close() + link.close() + finder.deinit() + display.close() diff --git a/packages/cast.json b/packages/cast.json index 6ae9204..10d903e 100644 --- a/packages/cast.json +++ b/packages/cast.json @@ -28,6 +28,10 @@ "cast/laptop_input.py", "../lib/examples/cast/laptop_input.py" ], + [ + "cast/medialink_tv.py", + "../lib/examples/cast/medialink_tv.py" + ], [ "cast/melody.py", "../lib/examples/cast/melody.py" diff --git a/packages/examples.json b/packages/examples.json index d7f6b59..2f779d0 100644 --- a/packages/examples.json +++ b/packages/examples.json @@ -208,6 +208,10 @@ "cast/laptop_input.py", "github:PyDevices/gallery/lib/examples/cast/laptop_input.py" ], + [ + "cast/medialink_tv.py", + "github:PyDevices/gallery/lib/examples/cast/medialink_tv.py" + ], [ "cast/melody.py", "github:PyDevices/gallery/lib/examples/cast/melody.py"