Audio capture and streaming app for webOS 5/6
Captures the TV's audio and sends it out over several transports. The
primary one is HyperHDR: RTP/L16 to a host-side loopback device, since
HyperHDR has no network audio input of its own. A second route renders
the spectrum on the TV and sends FlatBuffers images to port 19400
instead, for setups where touching the host's sound config is not an
option.
native/ the service: capture backends (PulseAudio, ALSA, exec,
test tone, all dlopen-based), DSP, and one file per sink
frontend/ D-pad driven UI at a fixed 1920x1080
servicefiles/ native service manifest plus the boot script
host/ RTP receiver and the loopback installer for the HyperHDR
machine
tools/ build/package, asset generation, Homebrew Channel
manifest, on-TV probe
test/ host-side suites: FlatBuffers and RTP verified against
real decoders, the engine end to end, the page in jsdom
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+217
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generates the app icons and the splash screen.
|
||||
|
||||
Written against nothing but the standard library on purpose: the icons are
|
||||
part of the package, so regenerating them must not depend on Pillow being
|
||||
installed or on a checked-in binary nobody can edit.
|
||||
|
||||
python3 tools/make-assets.py
|
||||
|
||||
Everything is drawn supersampled and boxed down, which is what gives the
|
||||
rounded corners and bar tops their edges.
|
||||
"""
|
||||
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ASSETS = os.path.join(HERE, os.pardir, "frontend", "assets")
|
||||
|
||||
# Same palette as the UI.
|
||||
BACKDROP = (11, 14, 19)
|
||||
TILE_TOP = (18, 32, 52)
|
||||
TILE_BOTTOM = (13, 17, 25)
|
||||
BAR_TOP = (74, 163, 255)
|
||||
BAR_BOTTOM = (31, 77, 118)
|
||||
ACCENT = (87, 217, 138)
|
||||
TEXT = (200, 214, 232)
|
||||
|
||||
# Fraction of the drawing height each bar reaches. Reads as a level meter
|
||||
# caught mid-song rather than a generic equaliser.
|
||||
BARS = [0.34, 0.62, 0.95, 0.48, 0.78, 0.40]
|
||||
|
||||
|
||||
class Canvas:
|
||||
"""RGBA pixel buffer with the handful of primitives this needs."""
|
||||
|
||||
def __init__(self, width, height, fill=(0, 0, 0, 0)):
|
||||
self.w = width
|
||||
self.h = height
|
||||
self.px = bytearray(fill * width * height) if len(fill) == 4 else None
|
||||
if self.px is None:
|
||||
self.px = bytearray((fill + (255,)) * width * height)
|
||||
|
||||
def blend(self, x, y, colour, alpha=255):
|
||||
if x < 0 or y < 0 or x >= self.w or y >= self.h or alpha <= 0:
|
||||
return
|
||||
i = (y * self.w + x) * 4
|
||||
if alpha >= 255:
|
||||
self.px[i:i + 4] = bytes(colour) + b"\xff"
|
||||
return
|
||||
a = alpha / 255.0
|
||||
for c in range(3):
|
||||
self.px[i + c] = int(self.px[i + c] * (1 - a) + colour[c] * a)
|
||||
self.px[i + 3] = max(self.px[i + 3], alpha)
|
||||
|
||||
def rect(self, x0, y0, x1, y1, colour):
|
||||
for y in range(max(0, int(y0)), min(self.h, int(y1))):
|
||||
for x in range(max(0, int(x0)), min(self.w, int(x1))):
|
||||
self.blend(x, y, colour)
|
||||
|
||||
def rounded_rect(self, x0, y0, x1, y1, radius, top, bottom=None):
|
||||
"""Filled rounded rectangle, optionally with a vertical gradient."""
|
||||
bottom = bottom if bottom is not None else top
|
||||
height = max(1, y1 - y0 - 1)
|
||||
for y in range(max(0, int(y0)), min(self.h, int(y1))):
|
||||
# Fractional edges mean y can sit just outside the span; clamping
|
||||
# keeps the gradient from extrapolating past either colour.
|
||||
t = min(1.0, max(0.0, (y - y0) / height))
|
||||
colour = tuple(int(top[c] + (bottom[c] - top[c]) * t) for c in range(3))
|
||||
for x in range(max(0, int(x0)), min(self.w, int(x1))):
|
||||
# Only the corners need the distance test.
|
||||
cx = None
|
||||
if x < x0 + radius and y < y0 + radius:
|
||||
cx, cy = x0 + radius, y0 + radius
|
||||
elif x >= x1 - radius and y < y0 + radius:
|
||||
cx, cy = x1 - radius - 1, y0 + radius
|
||||
elif x < x0 + radius and y >= y1 - radius:
|
||||
cx, cy = x0 + radius, y1 - radius - 1
|
||||
elif x >= x1 - radius and y >= y1 - radius:
|
||||
cx, cy = x1 - radius - 1, y1 - radius - 1
|
||||
if cx is not None:
|
||||
dx, dy = x - cx, y - cy
|
||||
if dx * dx + dy * dy > radius * radius:
|
||||
continue
|
||||
self.blend(x, y, colour)
|
||||
|
||||
def downsample(self, factor):
|
||||
"""Box filter. This is the whole anti-aliasing strategy."""
|
||||
w, h = self.w // factor, self.h // factor
|
||||
out = Canvas(w, h)
|
||||
area = factor * factor
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
r = g = b = a = 0
|
||||
for sy in range(factor):
|
||||
row = ((y * factor + sy) * self.w + x * factor) * 4
|
||||
for sx in range(factor):
|
||||
i = row + sx * 4
|
||||
r += self.px[i]
|
||||
g += self.px[i + 1]
|
||||
b += self.px[i + 2]
|
||||
a += self.px[i + 3]
|
||||
i = (y * w + x) * 4
|
||||
out.px[i] = r // area
|
||||
out.px[i + 1] = g // area
|
||||
out.px[i + 2] = b // area
|
||||
out.px[i + 3] = a // area
|
||||
return out
|
||||
|
||||
def paste(self, other, x0, y0):
|
||||
for y in range(other.h):
|
||||
for x in range(other.w):
|
||||
i = (y * other.w + x) * 4
|
||||
self.blend(x0 + x, y0 + y, tuple(other.px[i:i + 3]), other.px[i + 3])
|
||||
|
||||
def write_png(self, path):
|
||||
raw = bytearray()
|
||||
stride = self.w * 4
|
||||
for y in range(self.h):
|
||||
raw.append(0) # filter: none
|
||||
raw += self.px[y * stride:(y + 1) * stride]
|
||||
|
||||
def chunk(kind, data):
|
||||
head = struct.pack(">I", len(data)) + kind + data
|
||||
return head + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
|
||||
|
||||
png = b"\x89PNG\r\n\x1a\n"
|
||||
png += chunk(b"IHDR", struct.pack(">IIBBBBB", self.w, self.h, 8, 6, 0, 0, 0))
|
||||
png += chunk(b"IDAT", zlib.compress(bytes(raw), 9))
|
||||
png += chunk(b"IEND", b"")
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(png)
|
||||
|
||||
|
||||
# 5x7, only the letters the splash needs.
|
||||
GLYPHS = {
|
||||
"A": ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
|
||||
"C": ["01110", "10001", "10000", "10000", "10000", "10001", "01110"],
|
||||
"D": ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
|
||||
"I": ["11111", "00100", "00100", "00100", "00100", "00100", "11111"],
|
||||
"O": ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
|
||||
"P": ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
|
||||
"U": ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
|
||||
" ": ["00000"] * 7,
|
||||
}
|
||||
|
||||
|
||||
def text_width(text, scale, spacing):
|
||||
return len(text) * (5 * scale + spacing) - spacing
|
||||
|
||||
|
||||
def draw_text(canvas, text, x0, y0, scale, spacing, colour):
|
||||
x = x0
|
||||
for ch in text:
|
||||
rows = GLYPHS[ch]
|
||||
for ry, row in enumerate(rows):
|
||||
for rx, on in enumerate(row):
|
||||
if on == "1":
|
||||
canvas.rect(x + rx * scale, y0 + ry * scale,
|
||||
x + (rx + 1) * scale, y0 + (ry + 1) * scale, colour)
|
||||
x += 5 * scale + spacing
|
||||
|
||||
|
||||
def render_tile(size, supersample=4):
|
||||
"""The logo: a rounded tile with a level meter on it."""
|
||||
s = size * supersample
|
||||
c = Canvas(s, s)
|
||||
radius = int(s * 0.22)
|
||||
c.rounded_rect(0, 0, s, s, radius, TILE_TOP, TILE_BOTTOM)
|
||||
|
||||
margin = s * 0.18
|
||||
inner_w = s - margin * 2
|
||||
inner_h = s - margin * 2
|
||||
gap = inner_w / (len(BARS) * 4)
|
||||
bar_w = (inner_w - gap * (len(BARS) - 1)) / len(BARS)
|
||||
bar_radius = max(1, int(bar_w * 0.35))
|
||||
base = s - margin
|
||||
|
||||
for i, height in enumerate(BARS):
|
||||
x0 = margin + i * (bar_w + gap)
|
||||
top = base - inner_h * height
|
||||
colour_top = ACCENT if height > 0.9 else BAR_TOP
|
||||
c.rounded_rect(x0, top, x0 + bar_w, base, bar_radius, colour_top, BAR_BOTTOM)
|
||||
|
||||
return c.downsample(supersample)
|
||||
|
||||
|
||||
def render_splash(width=1920, height=1080):
|
||||
c = Canvas(width, height, BACKDROP)
|
||||
tile = render_tile(300, supersample=2)
|
||||
c.paste(tile, (width - tile.w) // 2, height // 2 - 260)
|
||||
|
||||
scale, spacing = 10, 10
|
||||
label = "AUDIO CAP"
|
||||
draw_text(c, label, (width - text_width(label, scale, spacing)) // 2,
|
||||
height // 2 + 120, scale, spacing, TEXT)
|
||||
return c
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(ASSETS, exist_ok=True)
|
||||
targets = [
|
||||
("icon.png", lambda: render_tile(80)),
|
||||
("largeIcon.png", lambda: render_tile(130)),
|
||||
("splash.png", render_splash),
|
||||
]
|
||||
for name, build in targets:
|
||||
path = os.path.join(ASSETS, name)
|
||||
build().write_png(path)
|
||||
print("wrote %s (%d bytes)" % (os.path.relpath(path), os.path.getsize(path)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user