# frank-tts-engine — the arm A TTS engine: qwentts.cpp's resident `tts-server` # (the CPU binary that frank-tts on :7772 forwards to). The runbook is # [[Frank TTS Engine]]; the fleet-facing endpoint is [[Frank TTS]]. # # WHAT THIS IS. The upstream source is NOT vendored here: the image clones # ServeurpersoCom/qwentts.cpp at one pinned commit and builds it. Rebuilding is # therefore a network operation (github.com plus objects.githubusercontent.com # for the `ggml` submodule), and build_compose_image in scripts/deploy-remote.sh # skips it whenever this Containerfile's sha256 label already matches the built # image — the same offline-safe idiom as loki/alloy/comfyui, so a re-deploy of # an unchanged engine needs no network. # # THE PINNED COMMIT IS THE CONTRACT. 5916bf7c79dbb4e66fc059cdc507a79a23a28bb4 # is the commit the 2026-10-18 CPU trial on Dankclaw measured (server-side TTFA # 94-112 ms, warm RTF 0.55-0.67, run spread +-0.01 — the numbers that won the # engine decision). Moving it means re-measuring, not just editing this line. # `ggml` is a submodule of the repo (the only one; vendor/cpp-httplib and # vendor/yyjson are ordinary directories in the tree), and the submodule commit # is recorded in the parent tree, so cloning the parent commit recursively is # reproducible without a second pin. # # THE BUILD LINE IS THE ONE PROVEN ON DANKCLAW, verbatim — with the Vulkan door # open since 2026-10-19. That configure was run in this exact base image and # answers `Found Vulkan: /usr/lib/x86_64-linux-gnu/libvulkan.so (found version # "1.3.275") found components: glslc` then `Including Vulkan backend`: # # cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CCACHE=OFF \ # -DGGML_BACKEND_DL=OFF -DGGML_CPU_ALL_VARIANTS=OFF -DGGML_OPENMP=ON \ # -DGGML_BLAS=OFF -DGGML_CUDA=OFF -DGGML_HIP=OFF -DGGML_VULKAN=ON \ # -DGGML_METAL=OFF -DGGML_SYCL=OFF -DGGML_RPC=OFF \ # && cmake --build build -j 8 # # Every flag is load-bearing, so do not "tidy" it: # GGML_BLAS=OFF is FORCED, not a preference — Dankclaw has libopenblas.so.0 # but no BLAS dev headers, so the upstream default (BLAS on) fails to # configure. Upstream's own Dockerfile and buildcpu.sh both pass BLAS on, # which is why this file does not copy them. # GGML_CCACHE=OFF because a container build layer has no persistent ccache # directory to hit; on, it only writes a useless one. # GGML_OPENMP=ON is what makes the runtime need libgomp1 (installed below). # GGML_BACKEND_DL=OFF / GGML_CPU_ALL_VARIANTS=OFF: upstream's images build # every x86 ISA as a loadable backend so one image runs anywhere. This one # is built on the machine that runs it, so the single native CPU backend is # what was measured. That is also why no -DGGML_NATIVE is passed: ggml # defaults it ON outside wasm/cross-compiles, i.e. -march=native for THIS # host — correct while build host == run host, wrong if anyone ever builds # this image elsewhere, which is why the fleet builds it on Dankclaw. # CUDA/HIP/METAL/SYCL/RPC stay OFF, VULKAN is ON: the endpoint computes on the # gfx1151 iGPU (decision and co-tenancy gates: # report/tts-npu-spike-verdict-2026-10-19.md, shape in [[Frank TTS Engine]]). # Nothing else is available to this image: no ROCm userland, no NVIDIA # userland, no macOS, no oneAPI, and RPC would add a second process to # supervise on a box that already has one engine per card. # # BUILD SHARED LIBS, RUNTIME SMALL. BUILD_SHARED_LIBS defaults ON on Linux, so # tts-server links libggml*.so rather than swallowing them; the runtime stage # copies the binary and its shared objects out of the build tree and points # LD_LIBRARY_PATH at them, exactly as upstream's own Dockerfile does (the RPATH # baked at link time names the build directory, which does not exist here). # # BASE PINNED BY DIGEST, like every other image this fleet builds: the digest # is the multi-arch INDEX of ubuntu:24.04 as resolved from Docker Hub on # 2026-10-18; Dankclaw takes the amd64 entry. One base for both stages so there # is exactly one thing to re-resolve. # # NO HEALTHCHECK IN THIS FILE. The probe lives in the Quadlet unit # (HealthCmd=), which is the fleet pattern for anything with a shell — see the # alertmanager/prometheus units, and the opposite case in dankclaw/loki, whose # distroless image has no /bin/sh for the generator's CMD-SHELL wrapping. # `curl` is therefore installed in the runtime stage, for that probe only. ARG BASE_IMAGE=docker.io/library/ubuntu:24.04@sha256:68ee22004a9109b44973313b02ff7b1de250f1de0f5ecb628d73f579aaff33ac # ------------------------------------------------------------------ build FROM ${BASE_IMAGE} AS build # The three Vulkan build deps are not optional and not interchangeable: # libvulkan-dev vulkan/vulkan.h + the VulkanHeaders/VulkanLoader CMake # configs, i.e. what find_package(Vulkan) resolves to. # glslc ggml compiles its shaders with glslc, not glslangValidator. # Ubuntu's glslang-tools ships glslangValidator and NOT glslc, # so without this package `find_package(Vulkan COMPONENTS # glslc REQUIRED)` fails and there is no Vulkan backend. # The archive's glslc is old (2023.8): it compiles # GL_KHR_cooperative_matrix — the path ggml's quantized # matmul needs — but reports cooperative_matrix2, # integer_dot_product, bfloat16 and the micro-float # extensions as unsupported, so those shader families are # compiled out. If a Vulkan prefill ever reads as # inexplicably slow, suspect this compiler before the device; # the fix is a pinned glslang build passed as # -DVulkan_GLSLC_EXECUTABLE=, not a different runtime. # spirv-headers find_package(SPIRV-Headers CONFIG REQUIRED); the distro # package ships /usr/share/cmake/SPIRV-Headers/. RUN apt-get update -qq \ && apt-get install -y -qq --no-install-recommends \ ca-certificates cmake g++ git make \ libvulkan-dev glslc spirv-headers \ && rm -rf /var/lib/apt/lists/* # Checked out detached at a full SHA, then the recorded submodule. No branch # name anywhere: a branch moves, this commit does not. ARG QWENTTS_COMMIT=5916bf7c79dbb4e66fc059cdc507a79a23a28bb4 RUN git clone --no-checkout https://github.com/ServeurpersoCom/qwentts.cpp /src \ && git -C /src checkout --detach "${QWENTTS_COMMIT}" \ && git -C /src submodule update --init --depth 1 \ && git -C /src rev-parse HEAD \ && git -C /src submodule status WORKDIR /src RUN cmake -B build -DCMAKE_BUILD_TYPE=Release \ -DGGML_CCACHE=OFF \ -DGGML_BACKEND_DL=OFF \ -DGGML_CPU_ALL_VARIANTS=OFF \ -DGGML_OPENMP=ON \ -DGGML_BLAS=OFF \ -DGGML_CUDA=OFF \ -DGGML_HIP=OFF \ -DGGML_VULKAN=ON \ -DGGML_METAL=OFF \ -DGGML_SYCL=OFF \ -DGGML_RPC=OFF \ && cmake --build build -j 8 # ---------------------------------------------------------------- runtime FROM ${BASE_IMAGE} # libgomp1: the OpenMP runtime the build above links against. curl: the unit's # HealthCmd. ca-certificates: so that curl can speak https if anyone ever # points a probe at a URL with a scheme this host terminates elsewhere. # # libvulkan1 + mesa-vulkan-drivers are what make `--device Vulkan0` mean # anything at runtime: libvulkan1 is the loader libggml-vulkan.so dlopens, and # mesa-vulkan-drivers is the RADV ICD that claims the gfx1151. Loader without an # ICD enumerates ZERO devices, and tts-server then dies in device lookup AFTER # both GGUFs have been read — the journal reads as a model fault, not a missing # driver. Enumerated from this base on 2026-10-19 with `--device /dev/dri # --group-add keep-groups` plus the host's ComfyUI udev rule: "Radeon 8060S # Graphics (RADV GFX1151)", API 1.4.318 (the heap figure is owned by # [[Frank TTS Engine]]; it is the GTT-backed aperture, not the ~2 GiB DRM # carve-out). No NVIDIA ICD is installed on purpose: one Vulkan device in the # enumeration is what makes the NAME Vulkan0 safe to write down. RUN apt-get update -qq \ && apt-get install -y -qq --no-install-recommends \ ca-certificates curl libgomp1 libvulkan1 mesa-vulkan-drivers \ && rm -rf /var/lib/apt/lists/* # Same unprivileged uid as the frank-tts façade (10000): the voice packs and the # model directory are host state mounted read-only, and one uid across the pair # means one thing to remember about what is world-readable. # # `useradd`, NOT `adduser`: this trimmed ubuntu:24.04 base ships no `adduser` at # all (verified 2026-10-18 against this digest — `adduser` is absent from the # base and is not pulled in by ca-certificates/curl/libgomp1 either), so an # `adduser` line dies at image-build time with crun's "executable file `adduser` # not found in $PATH". `useradd --system --uid 10000` succeeds and prints one # cosmetic warning (`uid 10000 is greater than SYS_UID_MAX 999`); the uid is the # point, and it is what matches the façade. The alpine façade Containerfile keeps # its own busybox `adduser -D -u 10000` — different base, different tool, same uid. RUN useradd --system --uid 10000 --home-dir /app --shell /usr/sbin/nologin tts WORKDIR /app COPY --from=build /src/build/tts-server /src/build/*.so* /app/ COPY dankclaw/voiceservice/tts-engine-start.sh /app/start.sh # The boot warmer lives INSIDE the image because the engine publishes no port: # the unit reaches it with `podman exec`, and the script needs the same curl, # base64 and /voices the engine's own namespace has. It is a sibling of # start.sh, not a second entrypoint — CMD stays the server. COPY dankclaw/voiceservice/tts-engine-warm.sh /app/warm-voices.sh RUN chmod 755 /app/start.sh /app/warm-voices.sh # The binary's RPATH points inside the build tree; this is what makes it find # the libggml*.so copied beside it. ENV LD_LIBRARY_PATH=/app USER tts EXPOSE 8880 CMD ["/app/start.sh"]