Build the service in a container, and fix what the target compiler found

The openlgtv NDK is a Linux toolchain with no macOS or Windows build, so
tools/build.sh could not produce a binary anywhere else. docker-build.sh
bakes the SDK into an image and compiles there; packaging and deploy stay
on the host, where the TV is reachable. The SDK ships aarch64 as well as
x86_64, so the image picks the one matching the daemon and Apple Silicon
builds natively rather than under emulation.

Cross-compiling for real turned up three things the host compiler did
not:

  sink_hyperhdr_viz.c  read p->width and p->height to format the error
                       message after free(p)
  sink_hyperhdr.c      an SDP connection line of 128 bytes cannot hold
                       "IN IP4 " plus a 127-byte host plus "/255", so a
                       long hostname would silently lose its TTL suffix
  common/log.c         the log body was sized to the whole ring line,
                       leaving nothing for the prefix; budget for it so
                       the bound is provable rather than left to
                       snprintf

A clean cross-compile is now warning-free, and readelf confirms the
design rule holds: luna-service2, glib, PmLogLib and libc, with no
libpulse or libasound.

Also: @webosose/ares-cli was pinned to ^3.0.0, which does not exist
(latest is 2.4.0), so npm install failed outright. build.sh now puts
node_modules/.bin on PATH so a local install is enough.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Rene Kievits
2026-08-26 10:29:09 +02:00
co-authored by Claude Opus 5
parent 7529a60650
commit 3e4d0e17bc
11 changed files with 5692 additions and 14 deletions
+1
View File
@@ -4,3 +4,4 @@ node_modules/
__pycache__/ __pycache__/
*.pyc *.pyc
.DS_Store .DS_Store
.vscode/
+10 -1
View File
@@ -61,7 +61,16 @@ Channel's *Install from file*, or from a workstation:
ares-install --device tv out/org.webosbrew.audiocap_1.0.0_all.ipk ares-install --device tv out/org.webosbrew.audiocap_1.0.0_all.ipk
``` ```
**From source.** See [docs/development.md](docs/development.md). **From source.** The NDK is Linux-only, so on macOS or Windows the compile goes
through Docker:
```sh
npm install # ares-cli
./tools/docker-build.sh # or ./tools/build.sh native on Linux
./tools/build.sh package # -> out/*.ipk
```
See [docs/development.md](docs/development.md) for the rest.
## First run ## First run
+29 -6
View File
@@ -4,17 +4,32 @@
| For | Install | | For | Install |
| --- | --- | | --- | --- |
| the native service | the [openlgtv buildroot NDK](https://github.com/openlgtv/buildroot-nc4/releases), `arm-webos-linux-gnueabi_sdk-buildroot` | | the native service | the [openlgtv buildroot NDK](https://github.com/openlgtv/buildroot-nc4/releases) — or Docker, see below |
| packaging | `npm install -g @webosose/ares-cli` | | packaging | `npm install` (ares-cli), or `npm install -g @webosose/ares-cli` |
| the tests | a host C compiler, Python 3, Node (optional: `flatbuffers`, `jsdom`) | | the tests | a host C compiler, Python 3, Node (optional: `flatbuffers`, `jsdom`) |
Unpack the NDK and relocate it once: ### The toolchain
The NDK is a **Linux** toolchain — there is no macOS or Windows build of it. On
Linux, unpack and relocate it once:
```sh ```sh
tar xf arm-webos-linux-gnueabi_sdk-buildroot.tar.gz -C "$HOME" tar xf arm-webos-linux-gnueabi_sdk-buildroot-x86_64.tar.gz -C "$HOME"
"$HOME/arm-webos-linux-gnueabi_sdk-buildroot/relocate-sdk.sh" "$HOME/arm-webos-linux-gnueabi_sdk-buildroot/relocate-sdk.sh"
``` ```
Everywhere else, build the native part in a container:
```sh
./tools/docker-build.sh # -> build/native/audiocap-service
```
That bakes the SDK into an image, so it downloads once and later builds start
immediately. There are aarch64 and x86_64 SDK builds and the image picks
whichever matches the container, so on Apple Silicon it runs natively rather
than under emulation. Only the compile happens in the container; packaging and
deployment run on the host, where the TV is reachable.
Register the TV with ares once, using the Homebrew Channel's ssh (port 9922, Register the TV with ares once, using the Homebrew Channel's ssh (port 9922,
root): root):
@@ -32,11 +47,19 @@ ares-setup-device --add tv \
./tools/build.sh logs ./tools/build.sh logs
``` ```
With the container toolchain it is two steps, since `build.sh` only knows how
to drive a local NDK:
```sh
./tools/docker-build.sh && ./tools/build.sh package
```
`DEVICE=livingroom ./tools/build.sh install` targets a different device; `DEVICE=livingroom ./tools/build.sh install` targets a different device;
`WEBOS_SDK=/opt/webos-sdk ./tools/build.sh` a differently placed NDK. `WEBOS_SDK=/opt/webos-sdk ./tools/build.sh` a differently placed NDK.
The same commands exist as npm scripts (`npm run build`, `npm run deploy`, …) The same commands exist as npm scripts (`npm run build`, `npm run deploy`, …)
if that is more your habit. if that is more your habit. `build.sh` puts `node_modules/.bin` on PATH first,
so a local `npm install` of ares-cli is enough — no global install needed.
### What the packaging step does ### What the packaging step does
@@ -92,7 +115,7 @@ npm run serve # http://localhost:8000
```sh ```sh
python3 tools/make-manifest.py \ python3 tools/make-manifest.py \
--base-url https://github.com/you/lgtv-audio-cap/releases/download/v1.0.0 --base-url https://git.crylia.de/Crylia/lgtv_audio_cap/releases/download/v1.0.0
``` ```
5. Submit the manifest URL to [webosbrew/repo](https://github.com/webosbrew/repo). 5. Submit the manifest URL to [webosbrew/repo](https://github.com/webosbrew/repo).
+6 -1
View File
@@ -9,6 +9,8 @@
#define RING_LINES 200 #define RING_LINES 200
#define RING_LINE_LEN 256 #define RING_LINE_LEN 256
// "HH:MM:SS.mmm [level] file.c:1234 " — 64 covers it with room to spare.
#define RING_LINE_PREFIX 64
static log_level_t s_level = LOG_INFO; static log_level_t s_level = LOG_INFO;
static pthread_mutex_t s_lock = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t s_lock = PTHREAD_MUTEX_INITIALIZER;
@@ -56,7 +58,10 @@ void log_printf(log_level_t level, const char* file, int line, const char* fmt,
snprintf(stamp, sizeof(stamp), "%02d:%02d:%02d.%03d", tm.tm_hour, tm.tm_min, snprintf(stamp, sizeof(stamp), "%02d:%02d:%02d.%03d", tm.tm_hour, tm.tm_min,
tm.tm_sec, (int)(tv.tv_usec / 1000)); tm.tm_sec, (int)(tv.tv_usec / 1000));
char body[RING_LINE_LEN]; // A ring line is a fixed size, so an over-long message is truncated rather
// than allowed to grow the buffer. Budgeting for the prefix here keeps the
// final snprintf provably within bounds instead of relying on it to clip.
char body[RING_LINE_LEN - RING_LINE_PREFIX];
va_list ap; va_list ap;
va_start(ap, fmt); va_start(ap, fmt);
vsnprintf(body, sizeof(body), fmt, ap); vsnprintf(body, sizeof(body), fmt, ap);
+3 -1
View File
@@ -79,7 +79,9 @@ static int build_sdp(hh_priv_t* p, char* out, size_t cap)
char src_str[INET_ADDRSTRLEN]; char src_str[INET_ADDRSTRLEN];
snprintf(src_str, sizeof(src_str), "%s", inet_ntoa(src)); snprintf(src_str, sizeof(src_str), "%s", inet_ntoa(src));
char conn[128]; // Wide enough for the longest host plus the prefix and the TTL suffix, so
// a long hostname cannot quietly lose its "/255" to truncation.
char conn[sizeof(p->host) + 16];
if (p->multicast) { if (p->multicast) {
// The /255 suffix is the TTL, required for multicast connection lines. // The /255 suffix is the TTL, required for multicast connection lines.
snprintf(conn, sizeof(conn), "IN IP4 %s/255", p->host); snprintf(conn, sizeof(conn), "IN IP4 %s/255", p->host);
+1 -1
View File
@@ -388,9 +388,9 @@ static sink_t* viz_open(const json_value_t* cfg, const audio_format_t* fmt, char
p->frame_bytes = (size_t)p->width * (size_t)p->height * 3; p->frame_bytes = (size_t)p->width * (size_t)p->height * 3;
p->frame = calloc(1, p->frame_bytes); p->frame = calloc(1, p->frame_bytes);
if (!p->frame) { if (!p->frame) {
snprintf(err, errlen, "out of memory allocating %dx%d frame", p->width, p->height);
free(p); free(p);
free(s); free(s);
snprintf(err, errlen, "out of memory allocating %dx%d frame", p->width, p->height);
return NULL; return NULL;
} }
+5512
View File
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -2,12 +2,20 @@
"name": "lgtv-audio-cap", "name": "lgtv-audio-cap",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"description": "Captures audio on an LG webOS 5/6 TV and streams it out — HyperHDR first, plus raw UDP, TCP and HTTP.", "description": "Captures audio on an LG webOS 5/6 TV and streams it out \u2014 HyperHDR first, plus raw UDP, TCP and HTTP.",
"keywords": ["webos", "lgtv", "hyperhdr", "hyperion", "audio", "webosbrew"], "keywords": [
"webos",
"lgtv",
"hyperhdr",
"hyperion",
"audio",
"webosbrew"
],
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"build": "tools/build.sh", "build": "tools/build.sh",
"native": "tools/build.sh native", "native": "tools/build.sh native",
"docker": "tools/docker-build.sh",
"stage": "tools/build.sh stage", "stage": "tools/build.sh stage",
"package": "tools/build.sh package", "package": "tools/build.sh package",
"deploy": "tools/build.sh install && tools/build.sh launch", "deploy": "tools/build.sh install && tools/build.sh launch",
@@ -21,7 +29,7 @@
"serve": "python3 -m http.server 8000 --directory frontend" "serve": "python3 -m http.server 8000 --directory frontend"
}, },
"devDependencies": { "devDependencies": {
"@webosose/ares-cli": "^3.0.0", "@webosose/ares-cli": "^2.4.0",
"jsdom": "^24.0.0" "jsdom": "^24.0.0"
} }
} }
+9
View File
@@ -17,6 +17,10 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT" cd "$ROOT"
# `npm install` puts ares-cli here rather than on PATH, so a local install works
# without also needing the global one.
[ -d "$ROOT/node_modules/.bin" ] && PATH="$ROOT/node_modules/.bin:$PATH"
APP_ID=org.webosbrew.audiocap APP_ID=org.webosbrew.audiocap
SERVICE_ID=$APP_ID.service SERVICE_ID=$APP_ID.service
BINARY=audiocap-service BINARY=audiocap-service
@@ -52,6 +56,11 @@ Download and unpack the buildroot NDK, then point WEBOS_SDK at it:
\$HOME/arm-webos-linux-gnueabi_sdk-buildroot/relocate-sdk.sh \$HOME/arm-webos-linux-gnueabi_sdk-buildroot/relocate-sdk.sh
WEBOS_SDK=\$HOME/arm-webos-linux-gnueabi_sdk-buildroot ./tools/build.sh WEBOS_SDK=\$HOME/arm-webos-linux-gnueabi_sdk-buildroot ./tools/build.sh
The SDK only runs on Linux. On macOS or Windows, compile in a container
instead and then come back here to package:
./tools/docker-build.sh && ./tools/build.sh package
EOF EOF
exit 1 exit 1
fi fi
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# Cross-compiles the service inside a container, for hosts that cannot run the
# NDK directly. The openlgtv buildroot SDK is a Linux toolchain, so on macOS or
# Windows this is the only way to build the native part.
#
# ./tools/docker-build.sh # build the service into build/native/
# ./tools/docker-build.sh shell # a prompt inside the SDK, for poking about
# ./tools/docker-build.sh clean # drop the image
#
# The SDK is baked into an image, so it is downloaded once (~350 MB) and every
# build after the first starts instantly. There are aarch64 and x86_64 builds
# of the SDK and the image picks the one matching the container, so this runs
# natively on Apple Silicon rather than under emulation.
#
# NDK_RELEASE pins the SDK release; DOCKER is podman if you prefer.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
DOCKER="${DOCKER:-docker}"
NDK_RELEASE="${NDK_RELEASE:-webos-a38c582}"
IMAGE="${IMAGE:-lgtv-audiocap-sdk:$NDK_RELEASE}"
SDK=/opt/webos-sdk
TOOLCHAIN="$SDK/share/buildroot/toolchainfile.cmake"
BUILD_DIR=build/native
say() { printf '%s\n' "$*"; }
step() { printf '\n== %s\n' "$*"; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
command -v "$DOCKER" >/dev/null 2>&1 || die "$DOCKER is not installed"
build_image() {
if "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1; then
return
fi
step "Building the SDK image (once, ~350 MB download)"
# The arch is resolved inside the build so the image is right for whatever
# the daemon runs, without the caller having to know.
"$DOCKER" build --tag "$IMAGE" - <<EOF
FROM debian:bookworm-slim
RUN apt-get update \\
&& apt-get install -y --no-install-recommends \\
ca-certificates curl cmake make file python3 \\
&& rm -rf /var/lib/apt/lists/*
RUN case "\$(uname -m)" in \\
aarch64|arm64) arch=aarch64 ;; \\
x86_64|amd64) arch=x86_64 ;; \\
*) echo "no webOS SDK for \$(uname -m)" >&2; exit 1 ;; \\
esac \\
&& mkdir -p $SDK \\
&& curl -fL "https://github.com/openlgtv/buildroot-nc4/releases/download/$NDK_RELEASE/arm-webos-linux-gnueabi_sdk-buildroot-\$arch.tar.gz" \\
| tar xz -C $SDK --strip-components=1 \\
&& $SDK/relocate-sdk.sh
EOF
}
# --user keeps build/ owned by the caller instead of root. HOME has to point
# somewhere writable or cmake complains about its package registry.
run() {
"$DOCKER" run --rm \
--volume "$ROOT:/src" \
--workdir /src \
--user "$(id -u):$(id -g)" \
--env HOME=/tmp \
"$@"
}
compile() {
build_image
# A cache from a differently placed toolchain cannot be reused.
if [ -f "$BUILD_DIR/CMakeCache.txt" ] \
&& ! grep -q "CMAKE_TOOLCHAIN_FILE:.*=$TOOLCHAIN" "$BUILD_DIR/CMakeCache.txt"; then
say "discarding a cmake cache from a different toolchain"
rm -rf "$BUILD_DIR"
fi
step "Cross-compiling the service"
run --interactive=false "$IMAGE" sh -c "
set -e
cmake -S native -B $BUILD_DIR \
-DCMAKE_TOOLCHAIN_FILE=$TOOLCHAIN \
-DCMAKE_BUILD_TYPE=Release
cmake --build $BUILD_DIR --parallel
file $BUILD_DIR/audiocap-service
"
[ -f "$BUILD_DIR/audiocap-service" ] || die "the build produced no binary"
say ""
say "built $BUILD_DIR/audiocap-service"
say "now: ./tools/build.sh package"
}
case "${1:-build}" in
build) compile ;;
shell)
build_image
step "A shell in the SDK image; the toolchain is at $SDK"
run --interactive --tty "$IMAGE" bash
;;
clean)
"$DOCKER" image rm "$IMAGE" 2>/dev/null && say "removed $IMAGE" || say "no image to remove"
;;
-h|--help)
awk 'NR == 1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$0" ;;
*) die "unknown command '$1' (try --help)" ;;
esac
+1 -1
View File
@@ -22,7 +22,7 @@ import sys
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(HERE, os.pardir) ROOT = os.path.join(HERE, os.pardir)
SOURCE_URL = "https://github.com/webosbrew/lgtv-audio-cap" SOURCE_URL = "https://git.crylia.de/Crylia/lgtv_audio_cap"
def newest_ipk(directory): def newest_ipk(directory):