Files
lgtv_audio_cap/tools/make-assets.py
T
Rene KievitsandClaude Opus 5 7529a60650 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>
2026-08-26 10:21:00 +02:00

218 lines
7.8 KiB
Python
Executable File

#!/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())