From 6f7aa7fc1b2060e7dae68efc3a6c1cd92168d586 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 21 Aug 2026 23:00:16 +0200 Subject: [PATCH] feat(nix): package the whisper-stt server The last native component. Without it resolveWhisperServer finds nothing, `openscreen captions` fails and the AI edition's transcription pump never starts. The model is deliberately not packaged. modelManager.ts downloads a GGML file from HuggingFace into userData on first use, checksums it and replaces a stale copy; that is a runtime cache the user owns, and baking a multi-gigabyte blob into the store would be wrong even if the sandbox allowed the download. The awkward part is that the CMakeLists pulls whisper.cpp, cpp-httplib and nlohmann/json with FetchContent at configure time, over a network the build does not have. Patching the CMakeLists was the wrong answer: those pins are deliberate and documented there -- one of them exists because a build once picked up OpenSSL from the host and shipped a binary that died in the Windows loader -- and a nix-only fork of them would drift from what every other platform builds. CMake already provides the override for this, so the three trees are fetched here and handed over through FETCHCONTENT_SOURCE_DIR_, with FETCHCONTENT_FULLY_DISCONNECTED so a missed one fails loudly rather than reaching for the network. fetchurl on pinned tags rather than fetchFromGitHub, which is a trade and not a preference. fetchFromGitHub hashes the unpacked tree and is immune to GitHub re-compressing an archive, but its hash cannot be computed or checked without nix, and there is no nix on the machine this was written on. A tarball hash can be verified by anyone with curl and sha256sum. If a tag is ever re-compressed the build fails closed and the fix is one line. Vulkan is on, matching what scripts/build-whisper-stt.sh selects for Linux. The alternative is a CPU-only binary, which works and is the same class of silent reduction this packaging exists to remove. Nothing is dlopen'd by soname here -- ggml links libvulkan normally -- so unlike the other two derivations no RPATH surgery is needed. OSC_NATIVE_CPU stays off per the CMakeLists' own warning: it compiles for whichever machine ran the build, and a nix package is precisely a thing built once and run elsewhere. Co-Authored-By: Claude --- .github/workflows/nix-build.yml | 3 +- flake.nix | 5 +- nix/package.nix | 4 +- nix/whisper-stt.nix | 155 ++++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 nix/whisper-stt.nix diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml index f53759676..2332e3d6b 100644 --- a/.github/workflows/nix-build.yml +++ b/.github/workflows/nix-build.yml @@ -172,7 +172,8 @@ jobs: for entry in \ "OPENSCREEN_FFMPEG_PATH:-x" \ "OPENSCREEN_COMPOSITOR_VIEW_NODE:-r" \ - "OPENSCREEN_LINUX_CURSOR_HELPER_EXE:-x"; do + "OPENSCREEN_LINUX_CURSOR_HELPER_EXE:-x" \ + "OPENSCREEN_WHISPER_SERVER_EXE:-x"; do var=${entry%:*} test=${entry##*:} # Read the value the wrapper exports rather than re-deriving it here, diff --git a/flake.nix b/flake.nix index 90e1e2609..be01a58b4 100644 --- a/flake.nix +++ b/flake.nix @@ -28,11 +28,12 @@ ffmpeg-lgpl = pkgs.callPackage ./nix/ffmpeg-lgpl.nix { }; compositor-view = pkgs.callPackage ./nix/compositor-view.nix { inherit ffmpeg-lgpl; }; pipewire-helper = pkgs.callPackage ./nix/pipewire-helper.nix { inherit ffmpeg-lgpl; }; + whisper-stt = pkgs.callPackage ./nix/whisper-stt.nix { }; in { - inherit compositor-view pipewire-helper; + inherit compositor-view pipewire-helper whisper-stt; openscreen = pkgs.callPackage ./nix/package.nix { - inherit compositor-view pipewire-helper; + inherit compositor-view pipewire-helper whisper-stt; }; default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen; } diff --git a/nix/package.nix b/nix/package.nix index 001ca1823..9e5dd1e23 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -6,6 +6,7 @@ ffmpeg-headless, compositor-view, pipewire-helper, + whisper-stt, makeWrapper, makeDesktopItem, copyDesktopItems, @@ -118,7 +119,8 @@ buildNpmPackage { --set ELECTRON_IS_DEV 0 \ --set OPENSCREEN_FFMPEG_PATH "${ffmpegLgpl}/bin/ffmpeg" \ --set OPENSCREEN_COMPOSITOR_VIEW_NODE "${compositor-view}/lib/compositor_view.node" \ - --set OPENSCREEN_LINUX_CURSOR_HELPER_EXE "${lib.getExe pipewire-helper}" + --set OPENSCREEN_LINUX_CURSOR_HELPER_EXE "${lib.getExe pipewire-helper}" \ + --set OPENSCREEN_WHISPER_SERVER_EXE "${lib.getExe whisper-stt}" # Install icons to hicolor theme for size in 16 24 32 48 64 128 256 512 1024; do diff --git a/nix/whisper-stt.nix b/nix/whisper-stt.nix new file mode 100644 index 000000000..831222bee --- /dev/null +++ b/nix/whisper-stt.nix @@ -0,0 +1,155 @@ +# The on-device speech-to-text server that backs captions. Without it +# resolveWhisperServer finds nothing, `openscreen captions` fails and the AI +# edition's transcription pump never starts. +# +# THE MODEL IS NOT PACKAGED, AND SHOULD NOT BE. modelManager.ts downloads a GGML +# file from HuggingFace into userData on first use, checksums it, and replaces a +# stale copy. That is a runtime concern with a cache the user owns; baking a +# multi-gigabyte blob into the store would be wrong even if the sandbox allowed +# the download, which it does not. +# +# WHAT MAKES THIS ONE AWKWARD. The CMakeLists pulls whisper.cpp, cpp-httplib and +# nlohmann/json with FetchContent at configure time, over the network, which a nix +# build does not have. Rather than patch the CMakeLists -- the pins are +# deliberate and documented there, and a nix-only fork of them would drift from +# what every other platform builds -- the three trees are fetched here and handed +# to FetchContent through FETCHCONTENT_SOURCE_DIR_, which is exactly the +# override CMake provides for this. FETCHCONTENT_FULLY_DISCONNECTED then makes a +# missed one fail loudly instead of silently reaching for the network. +{ + lib, + stdenv, + cmake, + fetchurl, + shaderc, + vulkan-headers, + vulkan-loader, +}: + +let + # fetchurl on a pinned tag, not fetchFromGitHub, and the difference is worth + # stating because it is a trade rather than a preference. fetchFromGitHub hashes + # the unpacked tree, which is immune to GitHub re-compressing an archive; it + # also cannot be computed or checked without nix. These hashes are of the + # tarball itself, so any reviewer can verify one with curl and sha256sum, on any + # platform -- which is the only way this file could be produced or reviewed from + # a machine with no nix on it. If GitHub ever re-compresses a tag, the build + # fails closed with a hash mismatch and the fix is one line. + whisperSrc = fetchurl { + url = "https://github.com/ggml-org/whisper.cpp/archive/refs/tags/v1.9.1.tar.gz"; + sha256 = "147267177eef7b22ec3d2476dd514d1b12e160e176230b740e3d1bd600118447"; + }; + httplibSrc = fetchurl { + url = "https://github.com/yhirose/cpp-httplib/archive/refs/tags/v0.18.1.tar.gz"; + sha256 = "405abd8170f2a446fc8612ac635d0db5947c0d2e156e32603403a4496255ff00"; + }; + jsonSrc = fetchurl { + url = "https://github.com/nlohmann/json/archive/refs/tags/v3.11.3.tar.gz"; + sha256 = "0d8ef5af7f9794e3263480193c491549b2ba6cc74bb018906202ada498a79406"; + }; +in +stdenv.mkDerivation { + pname = "openscreen-whisper-stt"; + version = (lib.importJSON ../package.json).version; + + # gitTracked for the same reason as the other two native derivations: a local + # build/ tree would otherwise land in the store and move the src hash on every + # cmake invocation. + src = + let + fs = lib.fileset; + isStorePath = + builtins.storeDir + == builtins.substring 0 (builtins.stringLength builtins.storeDir) (toString ../.); + baseFiles = if isStorePath then fs.fromSource (lib.cleanSource ../.) else fs.gitTracked ../.; + helper = ../electron/native/whisper-stt; + in + fs.toSource { + root = helper; + fileset = fs.intersection baseFiles helper; + }; + + nativeBuildInputs = [ + cmake + # glslc, for whisper.cpp's vulkan-shaders-gen. Only reachable with + # OSC_ENABLE_VULKAN=ON below, and the sub-project fails at configure time + # without it rather than degrading. + shaderc + ]; + + buildInputs = [ + vulkan-headers + vulkan-loader + ]; + + # OSC_ENABLE_VULKAN=ON is what scripts/build-whisper-stt.sh selects for + # linux-x64 and linux-arm64, and matching it is the point: the alternative is a + # CPU-only binary, which works but is the same class of silent reduction this + # packaging exists to remove. ggml links libvulkan normally here -- unlike the + # compositor addon and the PipeWire helper, nothing is dlopen'd by soname, so + # the linker records it and no RPATH surgery is needed. + # + # OSC_NATIVE_CPU is left off, deliberately and per the CMakeLists' own warning: + # ON would compile with -march=native for whichever machine ran the build, and + # a nix package is precisely a thing built once and run elsewhere. + cmakeFlags = [ + "-DCMAKE_BUILD_TYPE=Release" + "-DOSC_ENABLE_VULKAN=ON" + "-DFETCHCONTENT_FULLY_DISCONNECTED=ON" + ]; + + # The tarballs are unpacked before cmake runs and the flags are rewritten to + # point at them. Done here rather than in cmakeFlags above because the paths are + # only known once the archives are extracted, and $NIX_BUILD_TOP is not + # available at evaluation time. + preConfigure = '' + deps="$NIX_BUILD_TOP/fetchcontent" + mkdir -p "$deps" + tar -xzf ${whisperSrc} -C "$deps" + tar -xzf ${httplibSrc} -C "$deps" + tar -xzf ${jsonSrc} -C "$deps" + + cmakeFlagsArray+=( + "-DFETCHCONTENT_SOURCE_DIR_WHISPER=$deps/whisper.cpp-1.9.1" + "-DFETCHCONTENT_SOURCE_DIR_HTTPLIB=$deps/cpp-httplib-0.18.1" + "-DFETCHCONTENT_SOURCE_DIR_JSON=$deps/json-3.11.3" + ) + + for dir in "$deps"/whisper.cpp-1.9.1 "$deps"/cpp-httplib-0.18.1 "$deps"/json-3.11.3; do + test -d "$dir" || { + echo "expected unpacked source at $dir; the tarball layout changed" >&2 + exit 1 + } + done + ''; + + # Everything in one directory, binary and shared objects together. + # + # Not a nix convention, and deliberate: the CMakeLists sets + # CMAKE_INSTALL_RPATH to '$ORIGIN:$ORIGIN/bin' for Linux, so the binary looks + # for libwhisper.so and the ggml objects beside itself. That is also exactly how + # scripts/stage-whisper-stt.sh lays them out in electron/native/bin/linux-x64/. + # Splitting them into $out/bin and $out/lib would mean overriding an RPATH the + # upstream file chose on purpose, for the sake of a directory name. + installPhase = '' + runHook preInstall + mkdir -p "$out/bin" + cp whisper-stt-server "$out/bin/" + find . -name '*.so' -o -name '*.so.*' | while read -r so; do + cp "$so" "$out/bin/" + done + test -x "$out/bin/whisper-stt-server" || { + echo "whisper-stt-server was not produced" >&2 + exit 1 + } + runHook postInstall + ''; + + meta = { + description = "On-device speech-to-text server for OpenScreen captions"; + homepage = "https://github.com/getopenscreen/openscreen"; + license = lib.licenses.mit; + platforms = lib.platforms.linux; + mainProgram = "whisper-stt-server"; + }; +}