#!/usr/bin/env python3 """Build pinned libsndfile/Xiph/MPEG codecs for native WebEngine audio.""" import argparse import hashlib import json import os from pathlib import Path import subprocess import shutil import tarfile import urllib.request ROOT = Path(__file__).resolve().parents[2] VERSION = "1.2.2" # Blender build_files/build_environment/cmake/versions.cmake. ARCHIVE_MD5 = "04e2e6f726da7c5dc87f8cf72f250d04" # Debian's unmodified upstream tag archive of the same version (different # packaging from the upstream release tarball), retained for offline reuse. TAG_ARCHIVE_SHA256 = "ffe12ef8add3eaca876f04087734e6e8e029350082f3251f565fa9da55b52121" # Same releases and digests as Blender's versions.cmake. All algorithms remain # in the upstream native libraries; no browser-side encoding implementation. CODECS = ( ("ogg", "libogg-1.3.5", ".tar.gz", "0eb4b4b9420a0f51db142ba3f9c64b333f826532dc0f48c6410ae51f4799b664", []), ("vorbis", "libvorbis-1.3.7", ".tar.gz", "0e982409a9c3fc82ee06e08205b1355e5c6aa4c36bca58146ef399621b0ce5ab", []), ("flac", "flac-1.4.2", ".tar.xz", "e322d58a1f48d23d9dd38f432672865f6f79e73a6f9cc5a5f57fcaa83eb5a8e4", ["-DBUILD_CXXLIBS=OFF", "-DBUILD_PROGRAMS=OFF", "-DBUILD_EXAMPLES=OFF", "-DBUILD_DOCS=OFF", "-DBUILD_MANPAGES=OFF", "-DWITH_OGG=ON"]), ("opus", "opus-1.3.1", ".tar.gz", "65b58e1e25b2a114157014736a3d9dfeaad8d41be1c8179866f144a2fb44ff9d", ["-DOPUS_BUILD_PROGRAMS=OFF", "-DOPUS_BUILD_TESTING=OFF", "-DOPUS_INSTALL_PKG_CONFIG_MODULE=ON", "-DOPUS_STACK_PROTECTOR=OFF"]), ) # LAME matches Blender's versions.cmake. Blender uses FFmpeg for decoding and # does not pin mpg123: pin the original upstream archive required by libsndfile # separately. Digest from Debian's mpg123_1.32.10-1+deb13u1.dsc source metadata. MPEG_CODECS = ( ("lame-3.100", ".tar.gz", "md5", "83e260acbe4389b54fe08e0bdbf7cddb", "https://deb.debian.org/debian/pool/main/l/lame/lame_3.100.orig.tar.gz", ["--disable-frontend", "--disable-decoder", "--disable-analyzer-hooks", "--disable-nasm"]), ("mpg123-1.32.10", ".tar.xz", "sha256", "98867a796fa1eeb161847d211734d585ff61c56712fbc333de416fea7b59de29", "https://deb.debian.org/debian/pool/main/m/mpg123/mpg123_1.32.10.orig.tar.xz", ["--with-cpu=generic", "--disable-components", "--enable-libmpg123", "--disable-network"]), ) def build_mpeg(base, prefix, jobs, env): emscripten = ROOT / ".emsdk/upstream/emscripten" for release, suffix, algorithm, digest, url, options in MPEG_CODECS: source = base / release if not (source / "configure").is_file(): filename = release + suffix cached = [base / filename, Path.home() / "working/packages" / filename, Path.home() / "working/build_oiio_deps/downloads" / filename] archive = next((path for path in cached if path.is_file()), None) if archive is None: archive = cached[0] partial = archive.with_suffix(".partial") with urllib.request.urlopen(url, timeout=60) as response, partial.open("wb") as out: shutil.copyfileobj(response, out) partial.replace(archive) if hashlib.new(algorithm, archive.read_bytes()).hexdigest() != digest: raise RuntimeError(f"Pinned {release} archive digest mismatch") with tarfile.open(archive) as package: package.extractall(base, filter="data") build = base / (release + "-build") build.mkdir(exist_ok=True) if release.startswith("lame-"): # LAME 3.100's 2017 GNU config helper predates Emscripten. Reuse # the helper shipped with our embedded CPython, not a fake host # triplet which could enable native CPU assembly or ELF tooling. shutil.copy2(ROOT / "build_web_python313/source/config.sub", source / "config.sub") configure = [str(emscripten / "emconfigure"), str(source / "configure"), "--host=wasm32-unknown-emscripten", f"--prefix={prefix}", "--disable-shared", "--enable-static", *options] codec_env = dict(env, CFLAGS="-O2 -fPIC", CXXFLAGS="-O2 -fPIC") # Autoconf source lists remain upstream-owned; only rerun configuration # after build inputs change, leaving existing codec objects reusable. settings = json.dumps([configure, codec_env["CFLAGS"], codec_env["CXXFLAGS"]]) stamp = build / "web-configure.json" if not stamp.is_file() or stamp.read_text() != settings: subprocess.run(configure, cwd=build, env=codec_env, check=True) stamp.write_text(settings) subprocess.run([str(emscripten / "emmake"), "make", f"-j{jobs}", "install"], cwd=build, env=codec_env, check=True) def build_codecs(base, prefix, jobs, common, env): for family, release, suffix, digest, options in CODECS: source = base / release if not (source / "CMakeLists.txt").is_file(): filename = release + suffix cached = [base / filename, Path.home() / "working/packages" / filename] archive = next((path for path in cached if path.is_file()), None) if archive is None: archive = cached[0] partial = archive.with_suffix(".partial") url = f"https://downloads.xiph.org/releases/{family}/{filename}" with urllib.request.urlopen(url, timeout=60) as response, partial.open("wb") as out: shutil.copyfileobj(response, out) partial.replace(archive) if hashlib.sha256(archive.read_bytes()).hexdigest() != digest: raise RuntimeError(f"Blender-pinned {release} archive digest mismatch") with tarfile.open(archive) as package: package.extractall(base, filter="data") if family == "opus" and not (source / "opus_buildtype.cmake").is_file(): # The pinned 1.3.1 release omits this build-type defaults helper. # We set CMAKE_BUILD_TYPE explicitly. Keep the original source list # and configuration; only make the absent defaults include optional. cmake = source / "CMakeLists.txt" cmake.write_text(cmake.read_text().replace( "include(opus_buildtype.cmake)", "include(opus_buildtype.cmake OPTIONAL)")) build = base / (family + "-build") subprocess.run(["cmake", "-S", str(source), "-B", str(build), *common, "-DBUILD_TESTING=OFF", *options], cwd=ROOT, env=env, check=True) subprocess.run(["cmake", "--build", str(build), "--target", "install", "--parallel", str(jobs)], cwd=ROOT, env=env, check=True) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--source", type=Path) parser.add_argument("--archive", type=Path) parser.add_argument("--jobs", type=int, default=3) args = parser.parse_args() base = ROOT / "build_web_sndfile" base.mkdir(exist_ok=True) source = args.source if source is not None and not (source / "CMakeLists.txt").is_file(): raise FileNotFoundError(f"No libsndfile source at {source}") if source is None: local = (Path.home() / "working/build_oiio_deps/build/sndfile/src/" "external_sndfile") source = local if (local / "CMakeLists.txt").is_file() else base / f"libsndfile-{VERSION}" if not (source / "CMakeLists.txt").is_file(): if args.archive: archive = args.archive else: cached = [Path.home() / f"working/packages/libsndfile-{VERSION}.tar.xz", base / f"libsndfile-{VERSION}.tar.xz", base / f"libsndfile_{VERSION}.orig.tar.gz"] archive = next((path for path in cached if path.is_file()), None) if archive is None: archive = cached[-1] url = ("https://deb.debian.org/debian/pool/main/libs/libsndfile/" f"libsndfile_{VERSION}.orig.tar.gz") partial = archive.with_suffix(".partial") with urllib.request.urlopen(url, timeout=45) as response, partial.open("wb") as out: shutil.copyfileobj(response, out) partial.replace(archive) contents = archive.read_bytes() if (hashlib.md5(contents).hexdigest() != ARCHIVE_MD5 and hashlib.sha256(contents).hexdigest() != TAG_ARCHIVE_SHA256): raise RuntimeError("Blender-pinned libsndfile archive digest mismatch") with tarfile.open(archive) as package: package.extractall(base, filter="data") source = base / f"libsndfile-{VERSION}" prefix = base / "install" common = [ "-G", "Ninja", "-DCMAKE_TOOLCHAIN_FILE=" + str(ROOT / ".emsdk/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake"), "-DCMAKE_BUILD_TYPE=Release", "-DCMAKE_INSTALL_PREFIX=" + str(prefix), "-DEMSCRIPTEN_SYSTEM_PROCESSOR=wasm32", "-DCMAKE_INSTALL_LIBDIR=lib", "-DBUILD_SHARED_LIBS=OFF", "-DCMAKE_PREFIX_PATH=" + str(prefix), "-DCMAKE_FIND_ROOT_PATH=" + str(prefix), ] # Never let host pkg-config select ELF libraries for a WASM link. env = dict(os.environ, PKG_CONFIG_LIBDIR=str(prefix / "lib/pkgconfig"), PKG_CONFIG_PATH=str(prefix / "lib/pkgconfig")) build_codecs(base, prefix, args.jobs, common, env) build_mpeg(base, prefix, args.jobs, env) # libsndfile 1.2.2 only exposes Vorbis quality, although libvorbis already # implements managed bitrate encoding. Keep this native extension in a # reproducible source patch and install its command declaration with the # archive, so Audaspace cannot silently use an unpatched system header. marker = "SFC_WEB_SET_VORBIS_BITRATE" patched_header = marker in (source / "include/sndfile.h").read_text() patched_encoder = marker in (source / "src/ogg_vorbis.c").read_text() if patched_header != patched_encoder: raise RuntimeError("Incomplete WebEngine Vorbis patch in libsndfile source") if not patched_header: subprocess.run(["patch", "--batch", "--forward", "-p1", "-i", str(ROOT / "tools/web/patches/libsndfile-vorbis-bitrate.patch")], cwd=source, check=True) configure = [ "cmake", "-S", str(source), "-B", str(base / "build"), *common, "-DBUILD_PROGRAMS=OFF", "-DBUILD_EXAMPLES=OFF", "-DBUILD_TESTING=OFF", "-DENABLE_EXTERNAL_LIBS=ON", "-DENABLE_MPEG=ON", "-DINSTALL_MANPAGES=OFF", "-DINSTALL_PKGCONFIG_MODULE=ON", ] # Pin every find-module input, including entries cached by the old PCM build. for library, variable in (("ogg", "OGG"), ("vorbis", "Vorbis_Vorbis"), ("vorbisenc", "Vorbis_Enc"), ("vorbisfile", "Vorbis_File"), ("FLAC", "FLAC"), ("opus", "OPUS"), ("mp3lame", "MP3LAME"), ("mpg123", "mpg123")): configure += [f"-D{variable}_LIBRARY={prefix}/lib/lib{library}.a", f"-D{variable}_INCLUDE_DIR={prefix}/include"] subprocess.run(configure, cwd=ROOT, env=env, check=True) subprocess.run(["cmake", "--build", str(base / "build"), "--target", "install", "--parallel", str(args.jobs)], cwd=ROOT, env=env, check=True) if __name__ == "__main__": main()