Initial import: audio.cpp with Orange Pi documentation
Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled

This commit is contained in:
Fabio 2026-11-04 19:05:22 +01:00
commit c36510c21e
6068 changed files with 3165977 additions and 0 deletions

112
.devops/cpu.Dockerfile Normal file
View file

@ -0,0 +1,112 @@
# audio.cpp — CPU Dockerfile
#
# Usage:
# docker build -f .devops/cpu.Dockerfile -t local/audiocpp:full-cpu .
# ── BUILD: Compile all release binaries ───────────────────────────────────────
ARG UBUNTU_VERSION=24.04
ARG BUILD_DATE=N/A
ARG APP_VERSION=N/A
ARG APP_REVISION=N/A
ARG GCC_VERSION=14
FROM docker.io/ubuntu:$UBUNTU_VERSION AS build
ARG GCC_VERSION=14
ARG AUDIOCPP_VERSION=dev
# Install build toolchain
RUN apt-get update && \
apt-get install -y --no-install-recommends \
gcc-${GCC_VERSION} g++-${GCC_VERSION} make cmake libgomp1 ca-certificates && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
ENV CC=gcc-${GCC_VERSION} CXX=g++-${GCC_VERSION}
WORKDIR /app
COPY . .
# Validate architecture (CPU backend is only supported on amd64 and arm64)
ARG TARGETARCH=amd64
RUN if [ "$TARGETARCH" = "amd64" ] || [ "$TARGETARCH" = "arm64" ]; then \
echo "Building for $TARGETARCH"; \
else \
echo "Unsupported architecture: $TARGETARCH"; \
exit 1; \
fi
# Configure and build
RUN cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DAUDIOCPP_MODEL_SET=full \
-DENGINE_ENABLE_CPU_ALL_VARIANTS=ON \
-DENGINE_ENABLE_CUDA=OFF \
-DENGINE_ENABLE_VULKAN=OFF \
-DENGINE_ENABLE_OPENMP=ON \
-DAUDIOCPP_BUILD_NATIVE_MODEL_MANAGER=ON \
-DAUDIOCPP_VERSION="${AUDIOCPP_VERSION}" \
-DENGINE_BUILD_EXAMPLES=OFF \
-DENGINE_BUILD_TESTS=OFF \
-DENGINE_BUILD_WARMBENCH=OFF && \
cmake --build build --parallel $(nproc) \
--target audiocpp_cli \
--target audiocpp_server \
--target audiocpp_model_manager \
--target model_perf
# Collect shared libraries
RUN mkdir -p /app/lib && \
find build -name "*.so*" -exec cp -P {} /app/lib \;
# Collect binaries + multiplexer into /app/full
RUN mkdir -p /app/full && \
cp build/bin/audiocpp_cli build/bin/audiocpp_server build/bin/audiocpp_model_manager \
build/bin/model_perf /app/full/ && \
cp .devops/entrypoint.sh /app/full/entrypoint.sh && \
chmod +x /app/full/entrypoint.sh
# ── BASE: Shared runtime (OS + common libs) ───────────────────────────────────
FROM docker.io/ubuntu:$UBUNTU_VERSION AS base
ARG BUILD_DATE=N/A
ARG APP_VERSION=N/A
ARG APP_REVISION=N/A
ARG IMAGE_URL=N/A
ARG IMAGE_SOURCE=N/A
LABEL org.opencontainers.image.created=$BUILD_DATE \
org.opencontainers.image.version=$APP_VERSION \
org.opencontainers.image.revision=$APP_REVISION \
org.opencontainers.image.title="audio.cpp" \
org.opencontainers.image.description="An all-in-one, pure C++ inference engine for audio models, powered by ggml" \
org.opencontainers.image.url=$IMAGE_URL \
org.opencontainers.image.source=$IMAGE_SOURCE
# Runtime deps: OpenMP threading, curl (healthcheck), ffmpeg (audio I/O),
# python3 for the native WebUI's spec-backed model installer.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libgomp1 curl ffmpeg python3 ca-certificates && \
apt-get autoremove -y && \
apt-get clean -y && \
rm -rf /tmp/* /var/tmp/* && \
find /var/cache/apt/archives /var/lib/apt/lists -not -name lock -type f -delete && \
find /var/cache -type f -delete
COPY --from=build /app/lib/ /app
WORKDIR /app
# ── FULL: All binaries + entrypoint.sh multiplexer ────────────────────────────
FROM base AS full
COPY --from=build /app/full /app
COPY model_specs/ /app/model_specs/
COPY tools/model_manager_v2.py /app/tools/model_manager_v2.py
RUN mkdir -p /app/models && chown ubuntu:ubuntu /app/models
USER ubuntu
ENTRYPOINT ["/app/entrypoint.sh"]

116
.devops/cuda.Dockerfile Normal file
View file

@ -0,0 +1,116 @@
# audio.cpp — CUDA Dockerfile
#
# Usage:
# docker build -f .devops/cuda.Dockerfile -t local/audiocpp:full-cuda .
# ── BUILD: Compile all release binaries with CUDA ─────────────────────────────
ARG UBUNTU_VERSION=24.04
ARG CUDA_VERSION=12.9.2
ARG BUILD_DATE=N/A
ARG APP_VERSION=N/A
ARG APP_REVISION=N/A
ARG GCC_VERSION=14
ARG BASE_CUDA_DEV_CONTAINER=docker.io/nvidia/cuda:${CUDA_VERSION}-devel-ubuntu${UBUNTU_VERSION}
ARG BASE_CUDA_RUN_CONTAINER=docker.io/nvidia/cuda:${CUDA_VERSION}-runtime-ubuntu${UBUNTU_VERSION}
FROM ${BASE_CUDA_DEV_CONTAINER} AS build
ARG GCC_VERSION=14
ARG AUDIOCPP_VERSION=dev
# CUDA architectures to compile for.
# - default = the portable default list from CMakeLists.txt
# - for a custom arch set build with --build-arg CUDA_DOCKER_ARCH="89-real;...".
ARG CUDA_DOCKER_ARCH=default
# Install build toolchain
RUN apt-get update && \
apt-get install -y --no-install-recommends \
gcc-${GCC_VERSION} g++-${GCC_VERSION} cmake ca-certificates && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
ENV CC=gcc-${GCC_VERSION} CXX=g++-${GCC_VERSION} CUDAHOSTCXX=g++-${GCC_VERSION}
WORKDIR /app
COPY . .
RUN if [ "${CUDA_DOCKER_ARCH}" != "default" ]; then \
ADDITIONAL_CMAKE_ARGS="-DCMAKE_CUDA_ARCHITECTURES=${CUDA_DOCKER_ARCH}"; \
fi && \
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DAUDIOCPP_MODEL_SET=full \
-DENGINE_ENABLE_CPU_ALL_VARIANTS=ON \
-DENGINE_ENABLE_CUDA=ON \
-DENGINE_ENABLE_CUDA_GRAPHS=ON \
-DENGINE_ENABLE_VULKAN=OFF \
-DENGINE_ENABLE_OPENMP=ON \
-DAUDIOCPP_BUILD_NATIVE_MODEL_MANAGER=ON \
-DAUDIOCPP_VERSION="${AUDIOCPP_VERSION}" \
-DENGINE_BUILD_EXAMPLES=OFF \
-DENGINE_BUILD_TESTS=OFF \
-DENGINE_BUILD_WARMBENCH=OFF \
${ADDITIONAL_CMAKE_ARGS} \
-DCMAKE_EXE_LINKER_FLAGS=-Wl,--allow-shlib-undefined && \
cmake --build build --parallel $(nproc) \
--target audiocpp_cli \
--target audiocpp_server \
--target audiocpp_model_manager \
--target model_perf
# Collect shared libraries
RUN mkdir -p /app/lib && \
find build -name "*.so*" -exec cp -P {} /app/lib \;
# Collect binaries + multiplexer into /app/full
RUN mkdir -p /app/full && \
cp build/bin/audiocpp_cli build/bin/audiocpp_server build/bin/audiocpp_model_manager \
build/bin/model_perf /app/full/ && \
cp .devops/entrypoint.sh /app/full/entrypoint.sh && \
chmod +x /app/full/entrypoint.sh
# ── BASE: Shared runtime (NVIDIA CUDA + common libs) ──────────────────────────
FROM ${BASE_CUDA_RUN_CONTAINER} AS base
ARG BUILD_DATE=N/A
ARG APP_VERSION=N/A
ARG APP_REVISION=N/A
ARG IMAGE_URL=N/A
ARG IMAGE_SOURCE=N/A
LABEL org.opencontainers.image.created=$BUILD_DATE \
org.opencontainers.image.version=$APP_VERSION \
org.opencontainers.image.revision=$APP_REVISION \
org.opencontainers.image.title="audio.cpp" \
org.opencontainers.image.description="An all-in-one, pure C++ inference engine for audio models, powered by ggml" \
org.opencontainers.image.url=$IMAGE_URL \
org.opencontainers.image.source=$IMAGE_SOURCE
# Runtime deps: OpenMP threading, curl (healthcheck), ffmpeg (audio I/O),
# python3 for the native WebUI's spec-backed model installer.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libgomp1 curl ffmpeg python3 ca-certificates && \
apt-get autoremove -y && \
apt-get clean -y && \
rm -rf /tmp/* /var/tmp/* && \
find /var/cache/apt/archives /var/lib/apt/lists -not -name lock -type f -delete && \
find /var/cache -type f -delete
COPY --from=build /app/lib/ /app
WORKDIR /app
# ── FULL: All binaries + entrypoint.sh multiplexer ────────────────────────────
FROM base AS full
COPY --from=build /app/full /app
COPY model_specs/ /app/model_specs/
COPY tools/model_manager_v2.py /app/tools/model_manager_v2.py
RUN mkdir -p /app/models && chown ubuntu:ubuntu /app/models
USER ubuntu
ENTRYPOINT ["/app/entrypoint.sh"]

30
.devops/entrypoint.sh Executable file
View file

@ -0,0 +1,30 @@
#!/usr/bin/env bash
# ============================================================
# audio.cpp Docker multiplexer
#
# Dispatches to the correct binary based on the first
# argument. All remaining arguments are forwarded verbatim.
# ============================================================
set -e
arg1="$1"
shift || true
if [[ "$arg1" == "cli" ]]; then
exec ./audiocpp_cli "$@"
elif [[ "$arg1" == "server" ]]; then
exec ./audiocpp_server "$@"
elif [[ "$arg1" == "model-manager" ]]; then
exec ./audiocpp_model_manager "$@"
elif [[ "$arg1" == "perf" ]]; then
exec ./model_perf "$@"
else
echo "Unknown command: $arg1"
echo ""
echo "Available commands:"
echo " cli Run audio tasks (TTS, ASR, VAD, VC, diar, etc.)"
echo " server Run the HTTP server"
echo " model-manager List, install, clean, or remove model packages"
echo " perf Run model performance benchmarks"
exit 1
fi

132
.devops/nix/package.nix Normal file
View file

@ -0,0 +1,132 @@
{
lib,
stdenv,
cmake,
ninja,
pkg-config,
openssl,
rocmPackages,
cudaPackages,
vulkan-headers,
vulkan-loader,
vulkan-tools,
glslang,
shaderc,
python-scripts,
config,
version,
autoAddDriverRunpath,
# Overridable feature flags
cudaSupport ? config.cudaSupport or false,
vulkanSupport ? false,
metalSupport ? stdenv.hostPlatform.isDarwin,
rocmSupport ? config.rocmSupport or false,
rocmGpuTargets ? (lib.optionals rocmSupport rocmPackages.clr.gpuTargets),
strixHaloOptimizations ? (rocmSupport && rocmGpuTargets == [ "gfx1151" ]),
nativeModelManagerSupport ? true,
# Model selection: if non-empty, only these model targets are built.
# See CMakeLists.txt AUDIOCPP_MODEL_SET / AUDIOCPP_MODELS.
models ? [ ],
}:
stdenv.mkDerivation (finalAttrs: {
pname = "audio.cpp";
inherit version;
src = lib.cleanSource ../..;
nativeBuildInputs = [
cmake
ninja
pkg-config
]
++ lib.optional cudaSupport cudaPackages.cuda_nvcc
++ lib.optional cudaSupport autoAddDriverRunpath
++ lib.optional rocmSupport rocmPackages.clr
# ggml's HIP argsort path needs the hipcub and rocprim headers explicitly.
++ lib.optionals rocmSupport [
rocmPackages.hipcub
rocmPackages.rocprim
];
buildInputs = [
python-scripts
]
++ lib.optional nativeModelManagerSupport openssl
++ lib.optionals vulkanSupport [
vulkan-headers
vulkan-loader
vulkan-tools
glslang
shaderc
]
++ lib.optionals cudaSupport [
cudaPackages.cudatoolkit
]
++ lib.optionals rocmSupport [
rocmPackages.clr
rocmPackages.hipblas
rocmPackages.rocblas
];
cmakeFlags = [
"-DCMAKE_BUILD_TYPE=RelWithDebInfo"
"-DENGINE_ENABLE_NATIVE_CPU=ON"
"-DENGINE_ENABLE_LLAMAFILE=ON"
]
++ lib.optionals nativeModelManagerSupport [
"-DAUDIOCPP_BUILD_NATIVE_MODEL_MANAGER=ON"
"-DAUDIOCPP_USE_SYSTEM_OPENSSL=ON"
]
++ (
if models != [ ] then
[
"-DAUDIOCPP_MODEL_SET=custom"
"-DAUDIOCPP_MODELS=${lib.concatStringsSep "," models}"
]
else
[ "-DAUDIOCPP_MODEL_SET=full" ]
)
++ lib.optional stdenv.hostPlatform.isDarwin "-DENGINE_ENABLE_OPENMP=OFF"
++ lib.optional vulkanSupport "-DENGINE_ENABLE_VULKAN=ON"
++ lib.optional cudaSupport "-DENGINE_ENABLE_CUDA=ON"
++ lib.optional metalSupport "-DENGINE_ENABLE_METAL=ON"
++ lib.optional rocmSupport "-DENGINE_ENABLE_HIP=ON"
++ lib.optional rocmSupport "-DCMAKE_HIP_COMPILER=${rocmPackages.llvm.clang}/bin/clang"
++ lib.optional rocmSupport "-DGPU_TARGETS=${lib.concatStringsSep ";" rocmGpuTargets}"
++ lib.optional strixHaloOptimizations "-DENGINE_HIP_STRIX_HALO_OPTIMIZATIONS=ON";
env = lib.optionalAttrs rocmSupport {
ROCM_PATH = "${rocmPackages.clr}";
};
installPhase = ''
runHook preInstall
mkdir -p $out/bin
# Copy the built C++ executables directly from the bin directory
cp bin/audiocpp_cli bin/audiocpp_server bin/audiocpp_gguf $out/bin/
${lib.optionalString nativeModelManagerSupport ''
cp bin/audiocpp_model_manager $out/bin/
''}
# Keep the supported Python v2 manager available during migration without
# overwriting the native audiocpp_model_manager executable copied above.
install -Dm755 $src/tools/model_manager_v2.py $out/bin/audiocpp_model_manager_v2.py
cp -R $src/model_specs $out/model_specs
# Patch the shebang to use our python environment with torch/safetensors/pyyaml
patchShebangs $out/bin/audiocpp_model_manager_v2.py
runHook postInstall
'';
meta = with lib; {
description = "A high-performance C++ audio inference framework";
homepage = "https://github.com/0xShug0/audio.cpp";
license = licenses.mit;
platforms = platforms.unix;
mainProgram = "audiocpp_cli";
};
})

View file

@ -0,0 +1,11 @@
{
lib,
python3,
}:
python3.withPackages (ps: with ps; [
ps.torch
ps.safetensors
ps.pyyaml
ps.numpy
])

12
.devops/nix/scope.nix Normal file
View file

@ -0,0 +1,12 @@
{
lib,
newScope,
version,
python-scripts,
}:
lib.makeScope newScope (self: {
audiocpp = self.callPackage ./package.nix {
inherit version python-scripts;
};
})

152
.devops/rockchip.Dockerfile Normal file
View file

@ -0,0 +1,152 @@
# audio.cpp — Rockchip NPU (RKNN) Dockerfile, linux/arm64
#
# The CPU image (cpu.Dockerfile) runs on a Rockchip board, but its binaries
# carry no librknnrt: ENGINE_ENABLE_RKNN links the board runtime at build time,
# and that runtime is aarch64-only — so this is a separate Dockerfile, built on
# (or emulating) arm64.
#
# Usage (on the board, with the NPU reached through its device nodes):
# docker build --platform linux/arm64 -f .devops/rockchip.Dockerfile -t local/audio.cpp:full-rockchip .
# docker run --rm --device /dev/dri --device /dev/dma_heap \
# -v ./models:/app/models local/audio.cpp:full-rockchip server --help
#
# The --device flags are what the driver itself opens (straced on an RK3588
# board): with a mainline (rocket DRM) kernel the NPU is reached through the
# /dev/dri render nodes; a vendor BSP kernel exposes /dev/rknpu instead, so add
# `--device /dev/rknpu` there. Without the nodes the refusal is loud, not
# silent: `rknn_init failed with code -1` on the first plan step.
# ── RKNN SDK: the board runtime + its headers, pinned ─────────────────────────
# librknnrt.so ships in the airockchip/rknn-toolkit2 release tree; the image
# must carry the same version the engines were converted with (an engine is
# per-SoC and the runtime checks its version), so the tag is an ARG and the
# download is hash-checked.
ARG UBUNTU_VERSION=24.04
FROM docker.io/ubuntu:$UBUNTU_VERSION AS rknn-sdk
ARG RKNN_SDK_TAG=v2.3.2
ARG RKNN_SDK_BASE=https://raw.githubusercontent.com/airockchip/rknn-toolkit2
ARG RKNN_LIB_SHA256=d31fc19c85b85f6091b2bd0f6af9d962d5264a4e410bfb536402ec92bac738e8
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl && \
apt-get clean && rm -rf /var/lib/apt/lists/*
RUN mkdir -p /rknn/include && \
curl -fsSL -o /rknn/librknnrt.so \
"${RKNN_SDK_BASE}/${RKNN_SDK_TAG}/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so" && \
curl -fsSL -o /rknn/include/rknn_api.h \
"${RKNN_SDK_BASE}/${RKNN_SDK_TAG}/rknpu2/runtime/Linux/librknn_api/include/rknn_api.h" && \
curl -fsSL -o /rknn/include/rknn_matmul_api.h \
"${RKNN_SDK_BASE}/${RKNN_SDK_TAG}/rknpu2/runtime/Linux/librknn_api/include/rknn_matmul_api.h" && \
echo "${RKNN_LIB_SHA256} /rknn/librknnrt.so" | sha256sum -c -
# ── BUILD: compile the release binaries with the NPU path on ──────────────────
FROM docker.io/ubuntu:$UBUNTU_VERSION AS build
ARG UBUNTU_VERSION=24.04
ARG GCC_VERSION=14
ARG AUDIOCPP_VERSION=dev
RUN apt-get update && \
apt-get install -y --no-install-recommends \
gcc-${GCC_VERSION} g++-${GCC_VERSION} make cmake libgomp1 ca-certificates && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
ENV CC=gcc-${GCC_VERSION} CXX=g++-${GCC_VERSION}
WORKDIR /app
COPY . .
# librknnrt is aarch64 (armhf would be the other board arch); a native arm64
# runner builds this in the time the amd64 image takes.
RUN test "$(uname -m)" = "aarch64"
COPY --from=rknn-sdk /rknn/ /opt/rknn/
# Same shape as cpu.Dockerfile, plus the RKNN trio: the flag, the headers, the
# library. The RKNN sidecar sources join the build per linked model, so the
# full model set keeps working and every family with a board path gets one.
RUN cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DAUDIOCPP_MODEL_SET=full \
-DENGINE_ENABLE_CPU_ALL_VARIANTS=ON \
-DENGINE_ENABLE_CUDA=OFF \
-DENGINE_ENABLE_VULKAN=OFF \
-DENGINE_ENABLE_RKNN=ON \
-DENGINE_RKNN_INCLUDE_DIR=/opt/rknn/include \
-DENGINE_RKNN_LIBRARY=/opt/rknn/librknnrt.so \
-DENGINE_ENABLE_OPENMP=ON \
-DAUDIOCPP_BUILD_NATIVE_MODEL_MANAGER=ON \
-DAUDIOCPP_VERSION="${AUDIOCPP_VERSION}" \
-DENGINE_BUILD_EXAMPLES=OFF \
-DENGINE_BUILD_TESTS=OFF \
-DENGINE_BUILD_WARMBENCH=OFF && \
cmake --build build --parallel $(nproc) \
--target audiocpp_cli \
--target audiocpp_server \
--target audiocpp_model_manager \
--target model_perf
# Collect shared libraries (the driver runtime rides along in the $ORIGIN lib
# dir: librknnrt.so carries that SONAME, so the binaries' rpath finds it like
# any other collected library).
RUN mkdir -p /app/lib && \
find build -name "*.so*" -exec cp -P {} /app/lib \; && \
cp -P /opt/rknn/librknnrt.so /app/lib/
# Collect binaries + multiplexer into /app/full
RUN mkdir -p /app/full && \
cp build/bin/audiocpp_cli build/bin/audiocpp_server build/bin/audiocpp_model_manager \
build/bin/model_perf /app/full/ && \
cp .devops/entrypoint.sh /app/full/entrypoint.sh && \
chmod +x /app/full/entrypoint.sh
# ── BASE: shared runtime (OS + common libs + NPU driver runtime) ──────────────
FROM docker.io/ubuntu:$UBUNTU_VERSION AS base
ARG UBUNTU_VERSION=24.04
ARG BUILD_DATE=N/A
ARG APP_VERSION=N/A
ARG APP_REVISION=N/A
ARG IMAGE_URL=N/A
ARG IMAGE_SOURCE=N/A
LABEL org.opencontainers.image.created=$BUILD_DATE \
org.opencontainers.image.version=$APP_VERSION \
org.opencontainers.image.revision=$APP_REVISION \
org.opencontainers.image.title="audio.cpp (Rockchip NPU)" \
org.opencontainers.image.description="An all-in-one, pure C++ inference engine for audio models, powered by ggml, with the RKNN decoder path for Rockchip boards" \
org.opencontainers.image.url=$IMAGE_URL \
org.opencontainers.image.source=$IMAGE_SOURCE
# Runtime deps: OpenMP threading, curl (healthcheck), ffmpeg (audio I/O),
# python3 for the native WebUI's spec-backed model installer, and eSpeak-ng
# (the Piper/Inflect phonemizers dlopen libespeak-ng and need its data dir).
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libgomp1 curl ffmpeg python3 ca-certificates libespeak-ng1 espeak-ng-data && \
apt-get autoremove -y && \
apt-get clean -y && \
rm -rf /tmp/* /var/tmp/* && \
find /var/cache/apt/archives /var/lib/apt/lists -not -name lock -type f -delete && \
find /var/cache -type f -delete
COPY --from=build /app/lib/ /app
WORKDIR /app
# ── FULL: all binaries + entrypoint.sh multiplexer ────────────────────────────
FROM base AS full
COPY --from=build /app/full /app
COPY model_specs/ /app/model_specs/
COPY tools/model_manager_v2.py /app/tools/model_manager_v2.py
RUN mkdir -p /app/models && chown ubuntu:ubuntu /app/models
USER ubuntu
ENTRYPOINT ["/app/entrypoint.sh"]

116
.devops/vulkan.Dockerfile Normal file
View file

@ -0,0 +1,116 @@
# audio.cpp — Vulkan Dockerfile
#
# Usage:
# docker build -f .devops/vulkan.Dockerfile -t local/audiocpp:full-vulkan .
# ── BUILD: Compile all release binaries with Vulkan ──────────────────────────
ARG UBUNTU_VERSION=24.04
ARG BUILD_DATE=N/A
ARG APP_VERSION=N/A
ARG APP_REVISION=N/A
ARG GCC_VERSION=14
FROM docker.io/ubuntu:$UBUNTU_VERSION AS build
ARG GCC_VERSION=14
ARG AUDIOCPP_VERSION=dev
# Install build toolchain and Vulkan shader/compiler headers.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
gcc-${GCC_VERSION} g++-${GCC_VERSION} make cmake libgomp1 ca-certificates \
glslc libvulkan-dev spirv-headers \
libxcb-xinput0 libxcb-xinerama0 libxcb-cursor-dev && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
ENV CC=gcc-${GCC_VERSION} CXX=g++-${GCC_VERSION}
WORKDIR /app
COPY . .
# Validate architecture (Vulkan backend is only supported on amd64 and arm64)
ARG TARGETARCH=amd64
RUN if [ "$TARGETARCH" = "amd64" ] || [ "$TARGETARCH" = "arm64" ]; then \
echo "Building for $TARGETARCH"; \
else \
echo "Unsupported architecture: $TARGETARCH"; \
exit 1; \
fi
# Configure and build
RUN cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DAUDIOCPP_MODEL_SET=full \
-DENGINE_ENABLE_CPU_ALL_VARIANTS=ON \
-DENGINE_ENABLE_CUDA=OFF \
-DENGINE_ENABLE_VULKAN=ON \
-DENGINE_ENABLE_OPENMP=ON \
-DAUDIOCPP_BUILD_NATIVE_MODEL_MANAGER=ON \
-DAUDIOCPP_VERSION="${AUDIOCPP_VERSION}" \
-DENGINE_BUILD_EXAMPLES=OFF \
-DENGINE_BUILD_TESTS=OFF \
-DENGINE_BUILD_WARMBENCH=OFF && \
cmake --build build --parallel $(nproc) \
--target audiocpp_cli \
--target audiocpp_server \
--target audiocpp_model_manager \
--target model_perf
# Collect shared libraries
RUN mkdir -p /app/lib && \
find build -name "*.so*" -exec cp -P {} /app/lib \;
# Collect binaries + multiplexer into /app/full
RUN mkdir -p /app/full && \
cp build/bin/audiocpp_cli build/bin/audiocpp_server build/bin/audiocpp_model_manager \
build/bin/model_perf /app/full/ && \
cp .devops/entrypoint.sh /app/full/entrypoint.sh && \
chmod +x /app/full/entrypoint.sh
# ── BASE: Shared runtime (OS + Vulkan runtime libs) ──────────────────────────
FROM docker.io/ubuntu:$UBUNTU_VERSION AS base
ARG BUILD_DATE=N/A
ARG APP_VERSION=N/A
ARG APP_REVISION=N/A
ARG IMAGE_URL=N/A
ARG IMAGE_SOURCE=N/A
LABEL org.opencontainers.image.created=$BUILD_DATE \
org.opencontainers.image.version=$APP_VERSION \
org.opencontainers.image.revision=$APP_REVISION \
org.opencontainers.image.title="audio.cpp" \
org.opencontainers.image.description="An all-in-one, pure C++ inference engine for audio models, powered by ggml" \
org.opencontainers.image.url=$IMAGE_URL \
org.opencontainers.image.source=$IMAGE_SOURCE
# Runtime deps: OpenMP threading, Vulkan loader/Mesa ICDs, curl (healthcheck),
# ffmpeg (audio I/O), python3 for the native WebUI's spec-backed model installer.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libgomp1 libvulkan1 mesa-vulkan-drivers \
libglvnd0 libgl1 libglx0 libegl1 libgles2 \
curl ffmpeg python3 ca-certificates && \
apt-get autoremove -y && \
apt-get clean -y && \
rm -rf /tmp/* /var/tmp/* && \
find /var/cache/apt/archives /var/lib/apt/lists -not -name lock -type f -delete && \
find /var/cache -type f -delete
COPY --from=build /app/lib/ /app
WORKDIR /app
# ── FULL: All binaries + entrypoint.sh multiplexer ────────────────────────────
FROM base AS full
COPY --from=build /app/full /app
COPY model_specs/ /app/model_specs/
COPY tools/model_manager_v2.py /app/tools/model_manager_v2.py
RUN mkdir -p /app/models && chown ubuntu:ubuntu /app/models
USER ubuntu
ENTRYPOINT ["/app/entrypoint.sh"]

23
.dockerignore Normal file
View file

@ -0,0 +1,23 @@
.git/
.gitignore
.github/
examples/
scripts/
docs/
tools/*
!tools/gguf_convert.cpp
!tools/model_manager_v2.py
build/
build*/
models/
reference/
logs/
resources/
.cache/
nocommit/
patches/
perf/
target_proj/
model_specs_v1/
*.md
*.wav

25
.gitattributes vendored Normal file
View file

@ -0,0 +1,25 @@
# Windows command scripts should use CRLF.
*.bat text eol=crlf
*.cmd text eol=crlf
# Binary, media, model, and archive files.
*.7z binary
*.bin binary
*.bmp binary
*.dll binary
*.dylib binary
*.exe binary
*.flac binary
*.gguf binary
*.gif binary
*.ico binary
*.jpeg binary
*.jpg binary
*.mp3 binary
*.onnx binary
*.png binary
*.safetensors binary
*.so binary
*.wav binary
*.webp binary
*.zip binary

352
.github/workflows/docker.yml vendored Normal file
View file

@ -0,0 +1,352 @@
# audio.cpp — Build and publish Docker images
#
# Produces multi-arch (amd64, arm64) images on ghcr.io:
# full-cpu (latest, mutable)
# full-cpu-YYYYMMDD-HHHHHHH (pinned to date + short SHA, immutable)
# full-cuda12 (latest, mutable)
# full-cuda12-YYYYMMDD-HHHHHHH (pinned to date + short SHA, immutable)
# full-cuda13 (latest, mutable)
# full-cuda13-YYYYMMDD-HHHHHHH (pinned to date + short SHA, immutable)
# full-vulkan (latest, mutable)
# full-vulkan-YYYYMMDD-HHHHHHH (pinned to date + short SHA, immutable)
# full-rockchip-2.3.2 (latest, mutable; the suffix is the
# full-rockchip-2.3.2-YYYYMMDD-HHHHHHH pinned librknnrt version — engines
# are converted per SDK release, so
# the runtime version is part of the
# image's identity; arm64 only)
name: Build and publish Docker images
on:
# Runs daily at 03:21 UTC
schedule:
- cron: '21 3 * * *'
# or when triggered manually
workflow_dispatch:
inputs:
force_build:
description: 'Force build even if no new commits'
type: boolean
default: true
version:
description: 'audio.cpp version string embedded in the binaries'
type: string
default: dev
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
packages: write
jobs:
# ── Check for new commits ───────────────────────────────────────────────────
check-commits:
name: Check for new commits
runs-on: ubuntu-24.04
outputs:
skip_build: ${{ steps.check.outputs.skip }}
short_sha: ${{ steps.check.outputs.short_sha }}
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Check for new commits
id: check
run: |
SHORT_SHA=$(git rev-parse --short=7 HEAD)
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.force_build }}" == 'true' ]]; then
echo "Manual trigger (force_build=true) — building"
echo "skip=false" >> "$GITHUB_OUTPUT"
else
LAST_TAG=$(git tag -l 'last-docker-build' | head -1)
if [[ -n "$LAST_TAG" ]]; then
LAST_SHA=$(git rev-list -1 "$LAST_TAG" 2>/dev/null || echo "")
HEAD_SHA=$(git rev-parse HEAD)
if [[ "$LAST_SHA" == "$HEAD_SHA" ]]; then
echo "No new commits since last daily build — skipping"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "New commits found — building"
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
else
echo "No previous build tag found — building"
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
fi
# ── Shared metadata (date computed once for all jobs) ───────────────────────
metadata:
name: Generate metadata
needs: check-commits
if: ${{ needs.check-commits.outputs.skip_build != 'true' }}
runs-on: ubuntu-24.04
outputs:
build_date: ${{ steps.date.outputs.date }}
date_tag: ${{ steps.date.outputs.tag }}
app_version: ${{ steps.version.outputs.version }}
image_repo: ${{ steps.image_repo.outputs.image_repo }}
steps:
- name: Get build date
id: date
run: |
echo "date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
echo "tag=$(date -u +'%Y%m%d')" >> "$GITHUB_OUTPUT"
- name: Resolve app version
id: version
run: |
VERSION="${{ github.event.inputs.version }}"
if [[ -z "$VERSION" ]]; then
VERSION="dev"
fi
VERSION="${VERSION#v}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Determine lowercase image repository
id: image_repo
run: echo "image_repo=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
# ── Build each arch, push by digest ─────────────────────────────────────────
build:
name: Build (${{ matrix.tag }}, ${{ matrix.platforms }})
needs: [check-commits, metadata]
if: ${{ needs.check-commits.outputs.skip_build != 'true' }}
runs-on: ${{ matrix.runs_on }}
strategy:
fail-fast: false
matrix:
include:
# ── CPU ──
- tag: cpu
dockerfile: .devops/cpu.Dockerfile
platforms: linux/amd64
arch: amd64
runs_on: ubuntu-24.04
enabled: true
- tag: cpu
dockerfile: .devops/cpu.Dockerfile
platforms: linux/arm64
arch: arm64
runs_on: ubuntu-24.04-arm
enabled: true
# ── CUDA 12 ──
- tag: cuda12
dockerfile: .devops/cuda.Dockerfile
platforms: linux/amd64
arch: amd64
runs_on: ubuntu-24.04
cuda_version: "12.9.2"
enabled: true
- tag: cuda12
dockerfile: .devops/cuda.Dockerfile
platforms: linux/arm64
arch: arm64
runs_on: ubuntu-24.04-arm
cuda_version: "12.9.2"
enabled: true
# ── CUDA 13 ──
- tag: cuda13
dockerfile: .devops/cuda.Dockerfile
platforms: linux/amd64
arch: amd64
runs_on: ubuntu-24.04
cuda_version: "13.3.0"
enabled: true
- tag: cuda13
dockerfile: .devops/cuda.Dockerfile
platforms: linux/arm64
arch: arm64
runs_on: ubuntu-24.04-arm
cuda_version: "13.3.0"
enabled: true
# ── Vulkan ──
- tag: vulkan
dockerfile: .devops/vulkan.Dockerfile
platforms: linux/amd64
arch: amd64
runs_on: ubuntu-24.04
enabled: true
- tag: vulkan
dockerfile: .devops/vulkan.Dockerfile
platforms: linux/arm64
arch: arm64
runs_on: ubuntu-24.04-arm
enabled: true
# ── Rockchip NPU (arm64 only: librknnrt is aarch64-only) ──
- tag: rockchip-2.3.2
dockerfile: .devops/rockchip.Dockerfile
platforms: linux/arm64
arch: arm64
runs_on: ubuntu-24.04-arm
rknn_sdk_tag: v2.3.2
enabled: true
steps:
- name: Skip when not enabled
if: ${{ matrix.enabled != true }}
run: echo "Skipping ${{ matrix.tag }} build"
- name: Checkout
if: ${{ matrix.enabled == true }}
uses: actions/checkout@v5
- name: Set up Docker Buildx
if: ${{ matrix.enabled == true }}
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
if: ${{ matrix.enabled == true }}
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: push
if: ${{ matrix.enabled == true }}
uses: docker/build-push-action@v7
with:
context: .
file: ${{ matrix.dockerfile }}
target: full
platforms: ${{ matrix.platforms }}
provenance: false
outputs: type=image,name=ghcr.io/${{ needs.metadata.outputs.image_repo }},push-by-digest=true,name-canonical=true,push=true,oci-mediatypes=true
build-args: |
BUILD_DATE=${{ needs.metadata.outputs.build_date }}
APP_VERSION=${{ needs.metadata.outputs.app_version }}
AUDIOCPP_VERSION=${{ needs.metadata.outputs.app_version }}
APP_REVISION=${{ github.sha }}
IMAGE_URL=${{ github.server_url }}/${{ github.repository }}
IMAGE_SOURCE=${{ github.server_url }}/${{ github.repository }}
${{ matrix.cuda_version && format('CUDA_VERSION={0}', matrix.cuda_version) || '' }}
${{ matrix.rknn_sdk_tag && format('RKNN_SDK_TAG={0}', matrix.rknn_sdk_tag) || '' }}
cache-from: type=registry,ref=ghcr.io/${{ needs.metadata.outputs.image_repo }}:cache-${{ matrix.arch }}-${{ matrix.tag }}
cache-to: type=registry,ref=ghcr.io/${{ needs.metadata.outputs.image_repo }}:cache-${{ matrix.arch }}-${{ matrix.tag }},mode=max
- name: Upload digest
if: ${{ matrix.enabled == true }}
shell: bash
run: |
set -euo pipefail
mkdir -p /tmp/digests
digest="${{ steps.push.outputs.digest }}"
if [[ "$digest" != sha256:* ]]; then
echo "::error::Invalid digest format: $digest"
exit 1
fi
printf '%s\n' "$digest" > "/tmp/digests/${{ matrix.tag }}-${{ matrix.arch }}.txt"
- name: Save digest artifact
if: ${{ matrix.enabled == true }}
uses: actions/upload-artifact@v7
with:
name: digest-${{ matrix.arch }}-${{ matrix.tag }}
path: /tmp/digests/
if-no-files-found: error
# ── Merge per-arch digests into multi-arch manifests ────────────────────────
merge:
name: Merge (${{ matrix.tag }})
needs: [check-commits, metadata, build]
# Merge will only run if there was actually a new commit to act on and
# if all build jobs succeeded. As soon as one build fails, we won't
# produce any merged images.
if: ${{ needs.check-commits.outputs.skip_build != 'true' && needs.build.result == 'success' }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
tag: [cpu, cuda12, cuda13, vulkan, rockchip-2.3.2]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Download digests
uses: actions/download-artifact@v8
with:
pattern: digest-*
path: /tmp/digests
merge-multiple: true
- name: Create manifest
shell: bash
run: |
set -euo pipefail
repo="ghcr.io/${{ needs.metadata.outputs.image_repo }}"
date_tag="${{ needs.metadata.outputs.date_tag }}"
short_sha="${{ needs.check-commits.outputs.short_sha }}"
tag="${{ matrix.tag }}"
build_date="${{ needs.metadata.outputs.build_date }}"
image_url="${{ github.server_url }}/${{ github.repository }}"
mutable="full-${tag}"
immutable="full-${tag}-${date_tag}-${short_sha}"
# Collect per-arch digests.
# Because this job is gated on needs.build.result == 'success', a missing
# digest can only mean the variant was intentionally disabled (enabled: false).
expected_arches=(amd64 arm64)
refs=()
for arch in "${expected_arches[@]}"; do
file="/tmp/digests/${tag}-${arch}.txt"
[ -f "$file" ] || continue
refs+=("${repo}@$(cat "$file")")
done
echo "${tag}: ${#refs[@]}/${#expected_arches[@]} digests merged"
if [ ${#refs[@]} -eq 0 ]; then
echo " → skipping (all variants disabled)"
exit 0
fi
# OCI annotations for the merged manifest
annotations=(
--annotation "index:org.opencontainers.image.created=${build_date}"
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}"
--annotation "index:org.opencontainers.image.url=${image_url}"
--annotation "index:org.opencontainers.image.source=${image_url}"
)
echo " → ${mutable}"
docker buildx imagetools create "${annotations[@]}" --tag "${repo}:${mutable}" "${refs[@]}"
echo " → ${immutable}"
docker buildx imagetools create "${annotations[@]}" --tag "${repo}:${immutable}" "${refs[@]}"
# ── Update last-docker-build tag ────────────────────────────────────────────
update-tag:
name: Update build tag
needs: [check-commits, merge]
if: ${{ needs.check-commits.outputs.skip_build != 'true' && needs.merge.result == 'success' }}
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Update last-docker-build tag
run: |
set -euo pipefail
git tag -f last-docker-build HEAD
git push origin last-docker-build -f

78
.github/workflows/linux-build.yml vendored Normal file
View file

@ -0,0 +1,78 @@
name: Linux Build
on:
push:
branches:
- ci/**
- main
- dev
- release-0.2
pull_request:
branches:
- main
- dev
- release-0.2
workflow_dispatch:
jobs:
build:
name: Linux ${{ matrix.backend }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
- backend: cpu
enable_vulkan: "OFF"
cc: gcc-13
cxx: g++-13
- backend: vulkan
enable_vulkan: "ON"
cc: gcc-13
cxx: g++-13
- backend: cpu-clang
enable_vulkan: "OFF"
cc: clang-18
cxx: clang++-18
env:
CC: ${{ matrix.cc }}
CXX: ${{ matrix.cxx }}
BUILD_DIR: build/ci-linux-${{ matrix.backend }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check loader/spec sync
run: |
python3 tools/check_loader_catalog_sync.py --self-test
python3 tools/check_loader_catalog_sync.py
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y gcc-13 g++-13 cmake make glslc libvulkan-dev spirv-headers
- name: Install Clang
if: matrix.backend == 'cpu-clang'
run: sudo apt-get install -y clang-18 libomp-18-dev
- name: Configure
run: |
cmake -S . -B "$BUILD_DIR" \
-DCMAKE_BUILD_TYPE=Debug \
-DAUDIOCPP_VERSION=ci \
-DENGINE_ENABLE_CUDA=OFF \
-DENGINE_ENABLE_VULKAN=${{ matrix.enable_vulkan }} \
-DENGINE_BUILD_TESTS=ON
- name: Build
run: |
cmake --build "$BUILD_DIR" --parallel "$(nproc)" --target audiocpp_cli audiocpp_server audiocpp_gguf
- name: Build and run unit tests
run: |
cmake --build "$BUILD_DIR" --parallel "$(nproc)"
ctest --test-dir "$BUILD_DIR" --output-on-failure --parallel 4

58
.github/workflows/mac-build.yml vendored Normal file
View file

@ -0,0 +1,58 @@
name: mac-build
on:
push:
branches:
- ci/**
- main
- dev
- release-0.2
pull_request:
branches:
- main
- dev
- release-0.2
workflow_dispatch:
jobs:
build:
name: macOS CPU
runs-on: macos-26
env:
BUILD_DIR: build/ci-macos-cpu
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check loader/spec sync
run: |
python3 tools/check_loader_catalog_sync.py --self-test
python3 tools/check_loader_catalog_sync.py
- name: Check build helper scripts
run: ./scripts/check_build_scripts.sh
- name: Configure
run: |
cmake -S . -B "$BUILD_DIR" \
-DCMAKE_BUILD_TYPE=Debug \
-DAUDIOCPP_VERSION=ci \
-DENGINE_ENABLE_CUDA=OFF \
-DENGINE_ENABLE_VULKAN=OFF \
-DENGINE_ENABLE_METAL=OFF \
-DENGINE_ENABLE_OPENMP=OFF \
-DGGML_OPENMP=OFF \
-DENGINE_BUILD_TESTS=ON
- name: Build
run: |
cmake --build "$BUILD_DIR" \
--parallel "$(sysctl -n hw.logicalcpu)" \
--target audiocpp_cli audiocpp_server audiocpp_gguf
- name: Build and run unit tests
run: |
cmake --build "$BUILD_DIR" --parallel "$(sysctl -n hw.logicalcpu)"
ctest --test-dir "$BUILD_DIR" --output-on-failure --parallel 4

43
.github/workflows/nix-build.yml vendored Normal file
View file

@ -0,0 +1,43 @@
name: Nix Build
on:
push:
branches:
- ci/**
- main
- dev
- release-0.2
pull_request:
branches:
- main
- dev
- release-0.2
workflow_dispatch:
jobs:
build:
name: Nix (${{ matrix.package }})
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
- package: cpu
- package: vulkan
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v31
- name: Check loader/spec sync
run: |
python3 tools/check_loader_catalog_sync.py --self-test
python3 tools/check_loader_catalog_sync.py
- name: Build ${{ matrix.package }}
run: |
nix build ".#${{ matrix.package }}"

85
.github/workflows/release-hip.yml vendored Normal file
View file

@ -0,0 +1,85 @@
name: HIP test release binaries
on:
workflow_call:
inputs:
source_repository:
type: string
required: true
source_sha:
type: string
required: true
version:
type: string
required: true
permissions:
contents: read
actions: read
jobs:
build:
# DevLab availability and HIP build failures must not fail the release.
continue-on-error: true
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, windows-2022]
runs-on: ${{ fromJSON(matrix.os == 'windows-2022' && '["self-hosted", "windows", "devlab-dispatch", "strix-halo"]' || '["self-hosted", "linux", "devlab-dispatch", "strix-halo"]') }}
timeout-minutes: 90
env:
ROCM_VERSION: '10.0.0'
SOURCE_REPOSITORY: ${{ inputs.source_repository }}
RELEASE_VERSION: ${{ inputs.version }}
GPU_TARGETS: release
CCACHE_COMPILERCHECK: content
CCACHE_SLOPPINESS: time_macros,include_file_mtime,include_file_ctime
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts/ci
persist-credentials: false
- uses: actions/checkout@v4
with:
repository: ${{ inputs.source_repository }}
ref: ${{ inputs.source_sha }}
path: source
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Linux build dependencies
if: runner.os == 'Linux'
env:
# DevLab home directories are RAM-backed; use temporary scratch for pip's cache.
PIP_CACHE_DIR: ${{ runner.temp }}/pip-cache
run: |
python -m pip install ninja patchelf
tools="$RUNNER_TEMP/hip-release-tools"
mkdir -p "$tools"
curl -fL --retry 3 --max-time 120 -o "$tools/ccache.tar.xz" https://github.com/ccache/ccache/releases/download/v4.13.6/ccache-4.13.6-linux-x86_64-musl-static.tar.xz
tar -xJf "$tools/ccache.tar.xz" -C "$tools" --strip-components=1 ccache-4.13.6-linux-x86_64-musl-static/ccache
echo "$tools" >> "$GITHUB_PATH"
- uses: ilammy/msvc-dev-cmd@v1
if: runner.os == 'Windows'
- uses: ggml-org/ccache-action@v1.2.24
with:
key: hip-release-${{ runner.os }}-rocm10
max-size: 2G
install: ${{ runner.os == 'Linux' && 'no' || 'binary' }}
- name: Build and package deployment binaries
run: python scripts/ci/build_hip_release.py --source source --output dist
- uses: actions/upload-artifact@v4
if: always()
with:
name: hip-build-logs-${{ runner.os }}
path: dist/logs/
- uses: actions/upload-artifact@v4
with:
name: hip-release-${{ runner.os }}
path: |
dist/*.tar.gz
dist/*.zip
dist/SHA256SUMS
if-no-files-found: error
compression-level: 0

1089
.github/workflows/release.yml vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,160 @@
name: server lifecycle cross-platform
# Verifies server memory management and parallel-session lifecycle changes
# compile and the focused unit tests pass on all three
# desktop platforms. GPU backends are disabled here: the changes themselves are
# backend-agnostic (they query memory through ggml_backend_dev_memory), and the
# existing linux/mac/windows workflows already cover CUDA/Vulkan/Metal builds.
on:
workflow_dispatch:
pull_request:
paths:
- "app/server/**"
- "src/framework/core/host_memory.cpp"
- "include/engine/framework/core/backend.h"
- "src/framework/core/backend.cpp"
- "tests/unittests/test_server_config.cpp"
- "include/engine/framework/runtime/parallel_session.h"
- "include/engine/framework/runtime/session_pool.h"
- "include/engine/framework/runtime/session.h"
- "tests/unittests/test_server_model_slots.cpp"
- "tests/unittests/test_server_parallel_lifecycle.cpp"
- "tests/unittests/test_server_runtime_selection.cpp"
- "tests/unittests/test_session_pool.cpp"
- "tests/unittests/test_server_busy_guard.cpp"
- "include/engine/framework/core/shared_weight_cache.h"
- "src/framework/assets/tensor_source.cpp"
- "include/engine/framework/assets/tensor_source.h"
- "include/engine/framework/assets/resource_bundle.h"
- "src/framework/assets/resource_bundle.cpp"
- "include/engine/framework/io/json.h"
- "tests/unittests/test_gguf_tensor_source.cpp"
- "tests/unittests/test_shared_weight_cache.cpp"
- "src/framework/io/json.cpp"
- ".github/workflows/server-memory-guard.yml"
jobs:
build-test:
name: ${{ matrix.os }} build+test
runs-on: ${{ matrix.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, windows-2022, macos-26]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure (Linux/macOS)
if: runner.os != 'Windows'
run: |
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DAUDIOCPP_VERSION=ci \
-DENGINE_BUILD_TESTS=ON \
-DENGINE_BUILD_EXTENDED_TESTS=ON \
-DENGINE_ENABLE_OPENMP=OFF \
-DENGINE_ENABLE_CUDA=OFF \
-DENGINE_ENABLE_VULKAN=OFF \
-DENGINE_ENABLE_METAL=OFF
- name: Configure (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
cmake -S . -B build `
-DCMAKE_BUILD_TYPE=Release `
-DCMAKE_CXX_FLAGS="/utf-8 /EHsc" `
-DAUDIOCPP_VERSION=ci `
-DENGINE_BUILD_TESTS=ON `
-DENGINE_BUILD_EXTENDED_TESTS=ON `
-DENGINE_ENABLE_OPENMP=OFF `
-DENGINE_ENABLE_CUDA=OFF `
-DENGINE_ENABLE_VULKAN=OFF `
-DENGINE_ENABLE_METAL=OFF
- name: Build (Linux)
if: runner.os == 'Linux'
run: |
cmake --build build --target audiocpp_server parallel_http_live_body_test parallel_http_ownership_test server_config_test server_busy_guard_test server_runtime_selection_test server_model_slots_test server_parallel_lifecycle_test session_pool_test shared_weight_cache_test gguf_tensor_source_test --parallel "$(nproc)"
- name: Build (macOS)
if: runner.os == 'macOS'
run: |
cmake --build build --target audiocpp_server parallel_http_live_body_test parallel_http_ownership_test server_config_test server_busy_guard_test server_runtime_selection_test server_model_slots_test server_parallel_lifecycle_test session_pool_test shared_weight_cache_test gguf_tensor_source_test --parallel "$(sysctl -n hw.logicalcpu)"
- name: Build (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
cmake --build build --config Release --target audiocpp_server parallel_http_live_body_test parallel_http_ownership_test server_config_test server_busy_guard_test server_runtime_selection_test server_model_slots_test server_parallel_lifecycle_test session_pool_test shared_weight_cache_test gguf_tensor_source_test --parallel
- name: Run server lifecycle tests (Linux/macOS)
if: runner.os != 'Windows'
run: ctest --test-dir build --output-on-failure -R '^(parallel_http_live_body_test|parallel_http_ownership_test|server_config_test|server_busy_guard_test|server_runtime_selection_test|server_model_slots_test|server_parallel_lifecycle_test|session_pool_test|shared_weight_cache_test|gguf_tensor_source_test)$' --parallel 4
- name: Run server lifecycle tests (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: ctest --test-dir build --build-config Release --output-on-failure -R '^(parallel_http_live_body_test|parallel_http_ownership_test|server_config_test|server_busy_guard_test|server_runtime_selection_test|server_model_slots_test|server_parallel_lifecycle_test|session_pool_test|shared_weight_cache_test|gguf_tensor_source_test)$' --parallel 4
cpu-sanitizers:
name: CPU framework ${{ matrix.sanitizer }} sanitizer
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
sanitizer: [address, thread]
steps:
- uses: actions/checkout@v4
- name: Use TSan-compatible libstdc++ for timed mutexes
if: matrix.sanitizer == 'thread'
run: |
# GCC 13's pthread_mutex_clocklock path lacks a TSan interceptor.
# GCC 14 disables that path under TSan (gcc r14-905); keep BusyGuard
# unchanged and retain all tests and sanitizer diagnostics.
if ! command -v gcc-14 >/dev/null || ! command -v g++-14 >/dev/null; then
sudo apt-get update
sudo apt-get install -y gcc-14 g++-14
fi
echo "CC=gcc-14" >> "$GITHUB_ENV"
echo "CXX=g++-14" >> "$GITHUB_ENV"
g++-14 --version
- name: Configure instrumented CPU framework
run: |
cmake -S . -B build-sanitizer \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_C_FLAGS="-fsanitize=${{ matrix.sanitizer }} -fno-omit-frame-pointer" \
-DCMAKE_CXX_FLAGS="-fsanitize=${{ matrix.sanitizer }} -fno-omit-frame-pointer" \
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=${{ matrix.sanitizer }}" \
-DENGINE_BUILD_TESTS=ON -DENGINE_BUILD_EXTENDED_TESTS=ON \
-DENGINE_ENABLE_CUDA=OFF -DENGINE_ENABLE_VULKAN=OFF -DENGINE_ENABLE_METAL=OFF \
-DENGINE_ENABLE_OPENMP=OFF -DGGML_OPENMP=OFF \
-DENGINE_ENABLE_NATIVE_CPU=OFF -DENGINE_ENABLE_LLAMAFILE=OFF \
-DAUDIOCPP_MODEL_SET=custom -DAUDIOCPP_MODELS=builtin_audio_utils
- name: Build ownership and lifecycle regressions
run: |
cmake --build build-sanitizer --parallel 4 --target \
server_runtime_selection_test server_parallel_lifecycle_test server_model_slots_test \
session_pool_test shared_weight_cache_test gguf_tensor_source_test \
parallel_http_live_body_test parallel_http_ownership_test server_config_test server_busy_guard_test
- name: Run sanitizer regressions
env:
ASAN_OPTIONS: halt_on_error=1
TSAN_OPTIONS: halt_on_error=1
run: |
ctest --test-dir build-sanitizer --output-on-failure --timeout 60 \
--output-junit sanitizer-results.xml \
-R '^(server_runtime_selection_test|server_parallel_lifecycle_test|server_model_slots_test|session_pool_test|shared_weight_cache_test|gguf_tensor_source_test|parallel_http_live_body_test|parallel_http_ownership_test|server_config_test|server_busy_guard_test)$'
- name: Preserve sanitizer diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: cpu-framework-${{ matrix.sanitizer }}-sanitizer
path: |
build-sanitizer/sanitizer-results.xml
build-sanitizer/Testing/Temporary/LastTest.log

54
.github/workflows/windows-build.yml vendored Normal file
View file

@ -0,0 +1,54 @@
name: windows-build
on:
push:
branches:
- ci/**
- main
- dev
- release-0.2
pull_request:
branches:
- main
- dev
- release-0.2
workflow_dispatch:
jobs:
build:
name: Windows CPU
runs-on: windows-2022
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check loader/spec sync
shell: pwsh
run: |
python tools/check_loader_catalog_sync.py --self-test
python tools/check_loader_catalog_sync.py
- name: Build audiocpp_cli
shell: pwsh
run: |
.\scripts\build_windows.ps1 `
-Preset windows-cpu-release `
-Target audiocpp_cli `
-Version ci
- name: Build audiocpp_server
shell: pwsh
run: |
.\scripts\build_windows.ps1 `
-Preset windows-cpu-release `
-Target audiocpp_server `
-Version ci
- name: Build audiocpp_gguf
shell: pwsh
run: |
.\scripts\build_windows.ps1 `
-Preset windows-cpu-release `
-Target audiocpp_gguf `
-Version ci

52
.gitignore vendored Normal file
View file

@ -0,0 +1,52 @@
/build*/
/cmake-build*/
._*
.idea/
.vs/
.vscode/
compile_commands.json
*.obj
*.lib
*.exp
*.pdb
*.ilk
*.exe
__pycache__/
*.pyc
/venv/
/.venv/
/tests/assets/**/*.safetensors
/tests/omnivoice/outputs/
/unittests/
/models/
/granite5asr/
/reference/
/resources/
/patches/
/target_proj/
/tools_local/
/nocommit/
/perf/
/logs/
/ideas/
/windows_prebuild/
/*.wav
/flutter/
/.cache/
# --- portable bundle: distributed manually, never committed ---
/audiocpp-portable/
# --- Native WebUI build artifacts ---
/webui/native/node_modules/
/webui/native/.svelte-kit/
/webui/native/dist/*
!/webui/native/dist/index.html
audio-vtest-*
# local-only: 0.1B Falcon-H1 detailed port plan (keep local, not upstream)
docs/FALCON_H1_0.1B_PORT_PLAN.md
# Archivio locale della vecchia cronologia Git
/.git-upstream-archivio/

108
CMD.md Normal file
View file

@ -0,0 +1,108 @@
/tmp/audio-cpp-completo/build-cosyvoice3-rknn/bin/audiocpp_cli \
--task clon \
--family cosyvoice3 \
--model /home/nvme/audio-cpp-models/CosyVoice3-GGUF/cosyvoice3-q8_0.gguf \
--backend cpu \
--threads 4 \
--log \
--metrics \
--session-option 'cosyvoice3.exec_plan={"flow":{"impl":"rknn","artifacts":{"main":"/home/nvme/cosyvoice3-rknn-fp16-200/flow_estimator_seq200.rknn,/home/nvme/cosyvoice3-rknn-fp16-256/flow_estimator_seq256.rknn"}}}' \
--text "Ciao, come state?" \
--voice-ref /home/nvme/CosyVoice/mia_voce_2_3_9s.wav \
--reference-text "zia Ermia detta" \
--out /home/nvme/audio-cpp-models/test-cosyvoice3-seq256-verifica.wav
/tmp/audio-cpp-completo/build-cosyvoice3-rknn/bin/audiocpp_cli \
--task clon \
--family cosyvoice3 \
--model /home/nvme/audio-cpp-models/CosyVoice3-GGUF/cosyvoice3-q8_0.gguf \
--backend cpu \
--threads 4 \
--log \
--metrics \
--session-option 'cosyvoice3.exec_plan={"flow":{"impl":"rknn","artifacts":{"main":"/home/nvme/cosyvoice3-rknn-fp16-200/flow_estimator_seq200.rknn,/home/nvme/cosyvoice3-rknn-fp16-256/flow_estimator_seq256.rknn"}}}' \
--text "Ciao, come state oggi? Spero che stiate tutti bene." \
--voice-ref /home/nvme/CosyVoice/mia_voce_2_3_9s.wav \
--reference-text "zia Ermia detta" \
--out /home/nvme/audio-cpp-models/test-cosyvoice3-seq256-verifica3.wav
/tmp/audio-cpp-completo/build-cosyvoice3-rknn/bin/audiocpp_cli --task clon --family cosyvoice3 --model /home/nvme/audio-cpp-models/CosyVoice3-GGUF/cosyvoice3-q8_0.gguf --backend cpu --threads 4 --log --metrics --session-option 'cosyvoice3.exec_plan={"flow":{"impl":"rknn","artifacts":{"main":"/home/nvme/cosyvoice3-rknn-fp16-500/flow_estimator_seq500.rknn"}}}' --text "Buongiorno a tutti. Oggi vorrei raccontarvi una storia interessante. È una giornata speciale, piena di novità e di occasioni per imparare qualcosa di nuovo. Spero che possiate trascorrere momenti sereni, con entusiasmo e tanta voglia di fare. Vi auguro una splendida giornata e vi ringrazio per avermi ascoltato." --voice-ref /home/nvme/CosyVoice/mia_voce_2s.wav --reference-text "zia Ermia detta" --out /home/nvme/audio-cpp-models/test-cosyvoice3-seq500.wav
/tmp/audio-cpp-completo/build-cosyvoice3-rknn/bin/audiocpp_cli \
--task tts \
--family cosyvoice3 \
--model /home/nvme/audio-cpp-models/CosyVoice3-GGUF/cosyvoice3-q8_0.gguf \
--backend cpu \
--threads 4 \
--log \
--metrics \
--style-language it \
--speaking-rate 1.0 \
--request-option 'template_name=instruct' \
--request-option 'instruct=You are a helpful assistant. Read in Italian, in a warm, calm storytelling style, with natural pauses and gentle emphasis.<|endofprompt|>' \
--session-option 'cosyvoice3.exec_plan={"flow":{"impl":"rknn","artifacts":{"main":"/home/nvme/cosyvoice3-rknn-fp16-500/flow_estimator_seq500.rknn"}}}' \
--text "Buongiorno a tutti. Oggi vorrei raccontarvi una storia interessante. È una giornata speciale, piena di novità e di occasioni per imparare qualcosa di nuovo. Spero che possiate trascorrere momenti sereni, con entusiasmo e tanta voglia di fare. Vi auguro una splendida giornata e vi ringrazio per avermi ascoltato." \
--voice-ref /home/nvme/CosyVoice/mia_voce_2s.wav \
--reference-text "zia Ermia detta" \
--out /home/nvme/audio-cpp-models/test-cosyvoice3-seq500-emozionato.wav
/tmp/audio-cpp-completo/build-cosyvoice3-rknn/bin/audiocpp_cli \
--task tts \
--family cosyvoice3 \
--model /home/nvme/audio-cpp-models/CosyVoice3-GGUF/cosyvoice3-q8_0.gguf \
--backend cpu \
--threads 4 \
--log \
--metrics \
--style-language it \
--speaking-rate 1.0 \
--request-option 'template_name=instruct' \
--request-option 'instruct=You are a helpful assistant. Read in Italian, in a warm, calm storytelling style, with natural pauses and gentle emphasis.<|endofprompt|>' \
--session-option 'cosyvoice3.exec_plan={"flow":{"impl":"rknn","artifacts":{"main":"/home/nvme/cosyvoice3-rknn-fp16-500/flow_estimator_seq500.rknn"}}}' \
--text "Buongiorno a tutti. Oggi vorrei raccontarvi una storia interessante. È una giornata speciale, piena di novità e di occasioni per imparare qualcosa di nuovo. Spero che possiate trascorrere momenti sereni, con entusiasmo e tanta voglia di fare. Vi auguro una splendida giornata e vi ringrazio per avermi ascoltato." \
--voice-ref /home/nvme/CosyVoice/mia_voce_8s.wav \
--reference-text "sorella di Fitz, lady Ermia detta zia Erm, la zia povera, duchessa del Sussex" \
--out /home/nvme/audio-cpp-models/test-cosyvoice3-seq500-emozionato.wav
/tmp/audio-cpp-completo/build-cosyvoice3-rknn/bin/audiocpp_cli \
--task tts \
--family cosyvoice3 \
--model /home/nvme/audio-cpp-models/CosyVoice3-GGUF/cosyvoice3-q8_0.gguf \
--backend cpu \
--threads 4 \
--log \
--metrics \
--style-language it \
--speaking-rate 0.95 \
--request-option 'template_name=instruct' \
--session-option 'cosyvoice3.exec_plan={"flow":{"impl":"rknn","artifacts":{"main":"/home/nvme/cosyvoice3-rknn-fp16-500/flow_estimator_seq500.rknn"}}}' \
--text "Buongiorno a tutti. Oggi vorrei raccontarvi una storia interessante. È una giornata speciale, piena di novità e di occasioni per imparare qualcosa di nuovo. Spero che possiate trascorrere momenti sereni, con entusiasmo e tanta voglia di fare. Vi auguro una splendida giornata e vi ringrazio per avermi ascoltato." \
--voice-ref /home/nvme/CosyVoice/mia_voce_8s.wav \
--reference-text "sorella di Fitz, lady Ermia detta zia Erm, la zia povera, duchessa del Sussex" \
--out /home/nvme/audio-cpp-models/test-cosyvoice3-seq500-emozionato.wav
/tmp/audio-cpp-completo/build-cosyvoice3-rknn/bin/audiocpp_cli \
--task tts \
--family cosyvoice3 \
--model /home/nvme/audio-cpp-models/CosyVoice3-GGUF/cosyvoice3-q8_0.gguf \
--backend cpu \
--threads 4 \
--log \
--metrics \
--style-language it \
--speaking-rate 1.0 \
--request-option 'template_name=instruct' \
--request-option 'instruct=You are a helpful assistant. Read in Italian, in a warm, calm storytelling style, with natural pauses and gentle emphasis.<|endofprompt|>' \
--session-option 'cosyvoice3.exec_plan={"flow":{"impl":"rknn","artifacts":{"main":"/home/nvme/cosyvoice3-rknn-fp16-500/flow_estimator_seq500.rknn"}}}' \
--text "Buongiorno a tutti. Oggi vorrei raccontarvi una storia interessante. È una giornata speciale, piena di novità e di occasioni per imparare qualcosa di nuovo. Spero che possiate trascorrere momenti sereni, con entusiasmo e tanta voglia di fare. Vi auguro una splendida giornata e vi ringrazio per avermi ascoltato." \
--voice-ref /home/nvme/CosyVoice/mia_voce_2_3_9s.wav \
--reference-text "lady Ermia detta" \
--out /home/nvme/audio-cpp-models/test-cosyvoice3-seq500-emozionato.wav

5567
CMakeLists.txt Normal file

File diff suppressed because it is too large Load diff

161
COMPILAZIONE_ORANGE_PI.md Normal file
View file

@ -0,0 +1,161 @@
# Compilazione di audio.cpp su Orange Pi 5 Plus (RK3588)
Guida alla compilazione nativa su Armbian/Debian, con configurazioni separate per CPU, Vulkan e RKNN.
## 1. Ambiente di riferimento
- Scheda: Orange Pi 5 Plus
- SoC: Rockchip RK3588
- Architettura: ARM64 (`aarch64`)
- Sistema: Armbian 25.11 / Debian 13 Trixie
- Kernel: `6.1.115-vendor-rk35xx`
- GCC: 14.2
- CMake: 3.31.6
- Generatore: Unix Makefiles
- Build type: Release
- Runtime RKNN: 2.3.2
- Driver NPU: 0.9.8
Non aggiornare kernel, driver NPU o runtime RKNN senza aver prima verificato la compatibilità.
## 2. Controllo dei prerequisiti
Verificare gli strumenti di compilazione:
gcc --version
g++ --version
cmake --version
gmake --version
uname -m
L'architettura attesa è aarch64.
Verificare la libreria e gli header RKNN:
ls -l /usr/lib/librknnrt.so
ls -l /home/nvme/epub-gec-experiment/rknn-toolkit2-v2.3.2/rknpu2/runtime/Linux/librknn_api/include/rknn_api.h
Verificare i dispositivi grafici:
ls -l /dev/dri/
Sull'ambiente di riferimento è presente renderD129.
## 3. Directory di compilazione
Il repository si trova in:
/home/nvme/audio-cpp-originale
Usare una directory distinta per ogni configurazione:
- build-orangepi-cpu
- build-orangepi-rknn
- build-orangepi-vulkan-rknn
Non riutilizzare la stessa directory passando da una configurazione all'altra: CMake conserva le opzioni nella cache.
## 4. Compilazione CPU + RKNN
Questa configurazione abilita RKNN, CPU nativa, OpenMP e llamafile, senza Vulkan.
Configurazione:
cmake -S . -B build-orangepi-rknn -DCMAKE_BUILD_TYPE=Release -DENGINE_ENABLE_NATIVE_CPU=ON -DENGINE_ENABLE_OPENMP=ON -DENGINE_ENABLE_LLAMAFILE=ON -DENGINE_ENABLE_RKNN=ON -DENGINE_ENABLE_VULKAN=OFF -DENGINE_RKNN_INCLUDE_DIR=/home/nvme/epub-gec-experiment/rknn-toolkit2-v2.3.2/rknpu2/runtime/Linux/librknn_api/include -DENGINE_RKNN_LIBRARY=/usr/lib/librknnrt.so
Compilazione della CLI:
cmake --build build-orangepi-rknn --parallel 4 --target audiocpp_cli
Per compilare anche il server:
cmake --build build-orangepi-rknn --parallel 4 --target audiocpp_server
Verifica:
build-orangepi-rknn/bin/audiocpp_cli --help
## 5. Compilazione Vulkan + RKNN
Questa configurazione abilita contemporaneamente Vulkan e RKNN.
Configurazione:
cmake -S . -B build-orangepi-vulkan-rknn -DCMAKE_BUILD_TYPE=Release -DENGINE_ENABLE_NATIVE_CPU=ON -DENGINE_ENABLE_OPENMP=ON -DENGINE_ENABLE_LLAMAFILE=ON -DENGINE_ENABLE_RKNN=ON -DENGINE_ENABLE_VULKAN=ON -DENGINE_RKNN_INCLUDE_DIR=/home/nvme/epub-gec-experiment/rknn-toolkit2-v2.3.2/rknpu2/runtime/Linux/librknn_api/include -DENGINE_RKNN_LIBRARY=/usr/lib/librknnrt.so
Compilazione della CLI:
cmake --build build-orangepi-vulkan-rknn --parallel 4 --target audiocpp_cli
Verifica dei dispositivi:
build-orangepi-vulkan-rknn/bin/audiocpp_cli --list-devices
Sull'ambiente di riferimento il dispositivo Vulkan è il Mali-G610.
## 6. Compilazione CPU-only
Questa configurazione non richiede RKNN e non abilita Vulkan.
cmake -S . -B build-orangepi-cpu -DCMAKE_BUILD_TYPE=Release -DENGINE_ENABLE_NATIVE_CPU=ON -DENGINE_ENABLE_OPENMP=ON -DENGINE_ENABLE_LLAMAFILE=ON -DENGINE_ENABLE_RKNN=OFF -DENGINE_ENABLE_VULKAN=OFF
Compilazione della CLI:
cmake --build build-orangepi-cpu --parallel 4 --target audiocpp_cli
Se si verificano problemi con le istruzioni CPU native, si può provare una build portabile:
cmake -S . -B build-orangepi-cpu-portable -DCMAKE_BUILD_TYPE=Release -DENGINE_ENABLE_NATIVE_CPU=OFF -DENGINE_ENABLE_OPENMP=ON -DENGINE_ENABLE_LLAMAFILE=ON -DENGINE_ENABLE_RKNN=OFF -DENGINE_ENABLE_VULKAN=OFF
## 7. Verifica delle opzioni CMake
Per controllare le opzioni della build RKNN:
grep -E "^(CMAKE_BUILD_TYPE:|ENGINE_ENABLE_RKNN:|ENGINE_ENABLE_VULKAN:|ENGINE_ENABLE_OPENMP:|ENGINE_ENABLE_NATIVE_CPU:|ENGINE_ENABLE_LLAMAFILE:|ENGINE_RKNN_INCLUDE_DIR:|ENGINE_RKNN_LIBRARY:)" build-orangepi-rknn/CMakeCache.txt
Per la build Vulkan, sostituire build-orangepi-rknn con build-orangepi-vulkan-rknn.
Dopo aver modificato le opzioni, rieseguire cmake e poi la compilazione.
## 8. Pulizia di una singola build
Per ricreare una configurazione da zero, eliminare esclusivamente la relativa directory di build.
Esempio per la configurazione RKNN:
rm -rf build-orangepi-rknn
Dopo la rimozione, ripetere i comandi di configurazione e compilazione.
Non eliminare le altre directory di build.
## 9. Note sul backend RKNN
L’opzione ENGINE_ENABLE_RKNN=ON abilita il supporto al codice RKNN integrato nel progetto, ma non converte automaticamente i modelli e non trasferisce tutti i calcoli sulla NPU.
L’accelerazione NPU è disponibile soltanto per gli stadi che dispongono di un’integrazione RKNN specifica e di modelli compatibili con il SoC RK3588.
I modelli RKNN devono essere preparati separatamente e caricati dal runtime secondo il percorso previsto dal modello.
Alcuni stadi possono continuare a essere eseguiti sulla CPU o sulla GPU Vulkan. La presenza di RKNN nella configurazione non garantisce quindi un miglioramento delle prestazioni.
## 10. Verifica delle prestazioni
Confrontare sempre le diverse configurazioni usando lo stesso modello, gli stessi input e le stesse condizioni di test.
Per CosyVoice 3, nelle prove effettuate, l’esecuzione CPU-only del Flow è risultata più veloce rispetto all’integrazione RKNN con una configurazione fissa di 500 frame, quando l’input richiedeva 402 frame.
Questo risultato riguarda quella specifica prova e non deve essere generalizzato a tutti i modelli o a tutti gli stadi.
Quando possibile, misurare separatamente i tempi di LLM, Flow e HiFT. Mantenere la configurazione CPU-only come riferimento per valutare il vantaggio reale di Vulkan e RKNN.
## 11. Documentazione di riferimento
Per ulteriori dettagli consultare:
- docs/build/rockchip-npu.md: integrazione e compilazione per NPU Rockchip.
- README.md: panoramica generale del progetto.
- CMD.md: comandi e procedure di lavoro personali.
- README_fabio.md: note e configurazioni specifiche.
Questa guida descrive l’ambiente Orange Pi 5 Plus RK3588 verificato. Per altre schede o versioni di sistema, controllare prima la compatibilità delle librerie e dei driver.

220
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,220 @@
# Contributing
Thanks for helping improve audio.cpp. The project is growing quickly, so the most useful contributions are the ones that make the existing model surface easier to use, serve, compose, test, and maintain.
## Preferred Contribution Areas
High-impact areas right now:
- UI and app-facing workflows
- API server behavior, especially OpenAI-compatible serving. **Prioritize improvements to the legacy server runtime.** Work on `parallel_runtime` is not a current contribution priority; it remains experimental, with its design and scope still undecided.
- Pipeline and workflow subsystem improvements
- Community model ports
- Extending existing model families with additional variants
- Model documentation, examples, and validation reports
- Cross-platform build and packaging polish
> [!TIP]
> **High-impact optimization work:** as the framework evolves, newer models often gain better performance and memory-management patterns that older integrations have not yet adopted. Backporting those patterns is very useful, but it must be done model by model with careful regression tests for output quality, performance, and memory use.
These areas either improve shared surfaces used by many model families or make existing model support more complete. For already-supported model families, please add new variants to the existing implementation instead of creating a duplicate port.
## Pull Request Requirements
Keep PRs focused. A model port, a server change, a pipeline change, and a broad refactor are easier to review and validate when they are separate.
If a model port needs framework or runtime changes, split them when possible:
- Additive framework/runtime support can be included if it is clearly scoped, opt-in, and genuinely reusable by other models. Do not promote model-specific logic into the framework just because it is convenient; prefer a model-local implementation unless the shared abstraction is clearly useful beyond one model.
- Non-additive framework/runtime behavior changes should be a separate PR before the model port depends on them.
- Backend or GGML changes should be separate PRs so regressions (if any) are easier to trace.
If a PR changes runtime behavior, model implementation, backend logic, performance paths, or shared framework code, include actual runtime validation. Code inspection, generated tests, or "this should work" are not enough by themselves.
For optimization PRs, include before/after measurements. For behavior changes, explain whether outputs, performance, memory, or only docs/build wiring are affected, and include regression checks for the affected route.
**Please keep no more than 3 PRs open at the same time, including draft PRs.**
## Before Adding a Model
Please check the supported model table in [README.md](README.md), the community model docs, and open PRs/issues before starting a new model port. Many requests are better handled by extending an existing family with a new variant, route, GGUF package, backend fix, or UI surface.
If the model family is already listed, build on the existing implementation instead of opening a duplicate port. Add the new variant to the same family/spec/package layout unless there is a real architecture or task boundary that requires a separate family.
For new or updated model packages, keep the loader, model spec, docs, and UI/model-manager package entries aligned. Add or update the family's row in [docs/model_licenses.md](docs/model_licenses.md) too. A package should not advertise a model that the CLI/server cannot actually load, and a spec should not claim options or routes the implementation does not support.
Good follow-up work for existing model families includes:
- Better CLI or server examples
- More complete path tests
- Clearer model-manager package entries
- Additional GGUF variants or model-size variants
- Backend coverage improvements
- Memory, latency, or portability improvements
- Documentation for real user workflows
## New Model PRs
New standalone model ports should normally start under `community_models/`. This keeps ownership clear and lets useful model ports land with a lighter review bar than core framework models. Models can graduate into the core model tree later after they are validated, polished, and maintained as part of the main release surface.
Please read [Welcoming Community Models & Using Model Spec v1 for New Models](https://github.com/0xShug0/audio.cpp/issues/54) before starting a new model PR.
New models should use model spec v1, follow existing model/package naming conventions, and reuse framework modules, runtimes, helpers, and ownership patterns when they fit.
Even for community models, PRs should include enough evidence for maintainers and users to understand exactly what was tested. Follow the validation style shown in [PR #19](https://github.com/0xShug0/audio.cpp/pull/19) and [PR #63](https://github.com/0xShug0/audio.cpp/pull/63).
Please include:
- Exact build commands
- Exact run commands
- Model paths or model-manager package ids
- Generated output artifacts or paths
- Path-test or parity-test results
- Backend tested, such as CPU, CUDA, Vulkan, or Metal
- Relevant timing, RTF, RSS, VRAM, or resident-memory notes
- Known limitations
For TTS-style community models, the most useful validation includes a long-lived session with multiple requests, a long-form request through the framework text chunker, cache/graph reuse logs when relevant, and peak VRAM under repeated requests. These measurements do not need to be perfect on the first PR, but they make review much faster and help avoid a large cleanup pass after merge.
For models with Python references, include parity evidence when practical. For models without a clean reference path, include reproducible generated outputs and enough setup detail for another contributor to repeat the run.
Please be prepared to help maintain new model contributions as framework APIs evolve. Keeping model code aligned with the shared framework surface is part of making the implementation useful long term.
## Framework Modules (High Risk)
Framework modules are a high-impact but higher-risk contribution area because the internal framework APIs are still evolving quickly. Changes here can affect many model families at once, so please prefer additive work over modifying behavior that existing models already depend on.
If your contribution is a variant of an existing module, add it as a separate experimental module, for example `xxxExp`, instead of branching inside or rewriting the existing module. The new module can replace the existing one later, after it has shown no regressions across all models that rely on the current implementation.
## Acknowledgments
audio.cpp is moving faster because people keep showing up with real fixes, careful testing, and useful pressure on the parts that matter. Thank you to:
- [@mirek190](https://github.com/mirek190) for pushing the GGUF work forward across the converter, standalone package-spec loading, ASR GGUF support, the platform-neutral `audiocpp_gguf` binary, Qwen decoder improvements, OuteTTS, GLM-TTS, BS-RoFormer, Kroko ASR, Windows GGUF loading performance, model-spec v1 migrations, and Q8 CUDA residual graph fusion in [#8](https://github.com/0xShug0/audio.cpp/pull/8), [#43](https://github.com/0xShug0/audio.cpp/pull/43), [#45](https://github.com/0xShug0/audio.cpp/pull/45), [#46](https://github.com/0xShug0/audio.cpp/pull/46), [#53](https://github.com/0xShug0/audio.cpp/pull/53), [#62](https://github.com/0xShug0/audio.cpp/pull/62), [#63](https://github.com/0xShug0/audio.cpp/pull/63), [#68](https://github.com/0xShug0/audio.cpp/pull/68), [#79](https://github.com/0xShug0/audio.cpp/pull/79), [#97](https://github.com/0xShug0/audio.cpp/pull/97), [#98](https://github.com/0xShug0/audio.cpp/pull/98), [#114](https://github.com/0xShug0/audio.cpp/pull/114), [#122](https://github.com/0xShug0/audio.cpp/pull/122), [#123](https://github.com/0xShug0/audio.cpp/pull/123), [#124](https://github.com/0xShug0/audio.cpp/pull/124), [#126](https://github.com/0xShug0/audio.cpp/pull/126), [#140](https://github.com/0xShug0/audio.cpp/pull/140), [#141](https://github.com/0xShug0/audio.cpp/pull/141), [#142](https://github.com/0xShug0/audio.cpp/pull/142), and [#154](https://github.com/0xShug0/audio.cpp/pull/154); ACE-Step memory and performance improvements in [#737](https://github.com/0xShug0/audio.cpp/pull/737); Higgs Audio TTS CUDA prefill and KV-cache optimizations in [#669](https://github.com/0xShug0/audio.cpp/pull/669), [#704](https://github.com/0xShug0/audio.cpp/pull/704); RoFormer CUDA projection batching, chunk pipelining, and operation optimizations in [#632](https://github.com/0xShug0/audio.cpp/pull/632), [#633](https://github.com/0xShug0/audio.cpp/pull/633), [#666](https://github.com/0xShug0/audio.cpp/pull/666); a standalone YuE2 NAR parity probe in [#598](https://github.com/0xShug0/audio.cpp/pull/598); separating the legacy server runtime from experimental parallel jobs in [#715](https://github.com/0xShug0/audio.cpp/pull/715).
- [@justinjohn0306](https://github.com/justinjohn0306) for VibeVoice 7B, LoRA/fine-tune adapter loading, and the initial MOSS-TTS-Local model family implementation in [#14](https://github.com/0xShug0/audio.cpp/pull/14) and [#19](https://github.com/0xShug0/audio.cpp/pull/19).
- [@patrickjchen](https://github.com/patrickjchen) for CUDA build polish, safer constant tensor allocation, the server busy guard that keeps later requests from hanging behind a stuck model, the WebUI integration, English WebUI docs, the release 0.4 WebUI pass, and model-download fixes in [#72](https://github.com/0xShug0/audio.cpp/pull/72), [#73](https://github.com/0xShug0/audio.cpp/pull/73), [#75](https://github.com/0xShug0/audio.cpp/pull/75), [#87](https://github.com/0xShug0/audio.cpp/pull/87), [#90](https://github.com/0xShug0/audio.cpp/pull/90), [#108](https://github.com/0xShug0/audio.cpp/pull/108), and [#130](https://github.com/0xShug0/audio.cpp/pull/130).
- [@lapy](https://github.com/lapy) for the machine-readable loader/package catalog exports, loader-catalog sync checks, and model-spec dependency option checks that keep package metadata honest in [#74](https://github.com/0xShug0/audio.cpp/pull/74), [#86](https://github.com/0xShug0/audio.cpp/pull/86), and [#161](https://github.com/0xShug0/audio.cpp/pull/161).
- [@fedeizzo](https://github.com/fedeizzo) for the cross-platform Nix flake, follow-up Nix documentation polish, Strix Halo ROCm inference optimization, and Qwen3-TTS prompt modes without ICL reference codes in [#82](https://github.com/0xShug0/audio.cpp/pull/82), [#83](https://github.com/0xShug0/audio.cpp/pull/83), [#201](https://github.com/0xShug0/audio.cpp/pull/201), and [#202](https://github.com/0xShug0/audio.cpp/pull/202).
- [@phuocnguyen90](https://github.com/phuocnguyen90) for bringing VieNeu-TTS v3 Turbo into the community model surface in [#80](https://github.com/0xShug0/audio.cpp/pull/80).
- [@mosujiba](https://github.com/mosujiba) for adding configurable CORS handling to the server path in [#85](https://github.com/0xShug0/audio.cpp/pull/85).
- [@adambenhassen](https://github.com/adambenhassen) for PocketTTS runtime fixes and upstream-aligned English defaults in [#76](https://github.com/0xShug0/audio.cpp/pull/76) and [#77](https://github.com/0xShug0/audio.cpp/pull/77).
- [@vicenteliu](https://github.com/vicenteliu) for hardening the server against client disconnects by ignoring `SIGPIPE` in [#78](https://github.com/0xShug0/audio.cpp/pull/78).
- [@Cr4xy](https://github.com/Cr4xy) for improving multipart upload handling and removing temporary-file writes from that path in [#61](https://github.com/0xShug0/audio.cpp/pull/61).
- [@kevin-ho](https://github.com/kevin-ho) for making single-model server voice discovery work cleanly when the model parameter is omitted in [#64](https://github.com/0xShug0/audio.cpp/pull/64).
- [@xashr](https://github.com/xashr) for Dockerfiles, Docker examples, Docker documentation, portable CPU Docker builds, published-image docs, GGUF/Qwen3-TTS examples, CI workflow polish, CUDA probe cleanup, and portable CUDA architecture selection in [#30](https://github.com/0xShug0/audio.cpp/pull/30), [#51](https://github.com/0xShug0/audio.cpp/pull/51), [#81](https://github.com/0xShug0/audio.cpp/pull/81), [#84](https://github.com/0xShug0/audio.cpp/pull/84), [#107](https://github.com/0xShug0/audio.cpp/pull/107), [#109](https://github.com/0xShug0/audio.cpp/pull/109), and [#280](https://github.com/0xShug0/audio.cpp/pull/280).
- [@5uck1ess](https://github.com/5uck1ess) for improving Citrinet CTC decoding through the SentencePiece model, hardening PocketTTS FlowLM step allocation, and adding live PCM transcription ingest to the server in [#49](https://github.com/0xShug0/audio.cpp/pull/49), [#59](https://github.com/0xShug0/audio.cpp/pull/59), and [#144](https://github.com/0xShug0/audio.cpp/pull/144). Additional work includes Echo-TTS and broader WAV format support in [#180](https://github.com/0xShug0/audio.cpp/pull/180) and [#319](https://github.com/0xShug0/audio.cpp/pull/319); tag-aware closing-quote handling and upstream-aligned Drama3 sampling defaults in [#977](https://github.com/0xShug0/audio.cpp/pull/977), [#978](https://github.com/0xShug0/audio.cpp/pull/978).
- [@dkruyt](https://github.com/dkruyt) for the first multipart transcription upload support in [#25](https://github.com/0xShug0/audio.cpp/pull/25).
- [@CaptainArni](https://github.com/CaptainArni) for fixing PocketTTS empty output when switching cached voices, keeping the Windows CUDA build path healthy, adding ACE-Step 1.5 XL DiT variants, allowing finished GGUFs to be re-converted, and fixing Windows CUDA setup when `PATH` contains stray quotes in [#22](https://github.com/0xShug0/audio.cpp/pull/22), [#93](https://github.com/0xShug0/audio.cpp/pull/93), [#235](https://github.com/0xShug0/audio.cpp/pull/235), [#464](https://github.com/0xShug0/audio.cpp/pull/464), and [#511](https://github.com/0xShug0/audio.cpp/pull/511).
- [@IIIIIllllIIIIIlllll](https://github.com/IIIIIllllIIIIIlllll) for the experimental ROCm/HIP backend, Linux HIP build path, Windows HIP/ROCm distribution preparation, HIP build documentation, VibeVoice HIP enablement, backend device listing, Vulkan AMD integer-dot guard, IndexTTS-2.5 support, base64 voice references in server requests, IndexTTS2 speech-rate controls, IndexTTS2 HIP/text-normalization fixes, BreezeTTS 2 performance and VRAM improvements, ModelScope downloads, HIP build guards, and the Fish Audio HIP Fast-AR CMake target in [#48](https://github.com/0xShug0/audio.cpp/pull/48), [#148](https://github.com/0xShug0/audio.cpp/pull/148), [#153](https://github.com/0xShug0/audio.cpp/pull/153), [#159](https://github.com/0xShug0/audio.cpp/pull/159), [#164](https://github.com/0xShug0/audio.cpp/pull/164), [#168](https://github.com/0xShug0/audio.cpp/pull/168), [#171](https://github.com/0xShug0/audio.cpp/pull/171), [#193](https://github.com/0xShug0/audio.cpp/pull/193), [#210](https://github.com/0xShug0/audio.cpp/pull/210), [#226](https://github.com/0xShug0/audio.cpp/pull/226), [#239](https://github.com/0xShug0/audio.cpp/pull/239), [#247](https://github.com/0xShug0/audio.cpp/pull/247), [#259](https://github.com/0xShug0/audio.cpp/pull/259), [#299](https://github.com/0xShug0/audio.cpp/pull/299), [#305](https://github.com/0xShug0/audio.cpp/pull/305), [#393](https://github.com/0xShug0/audio.cpp/pull/393), [#431](https://github.com/0xShug0/audio.cpp/pull/431), [#450](https://github.com/0xShug0/audio.cpp/pull/450), [#480](https://github.com/0xShug0/audio.cpp/pull/480), and [#490](https://github.com/0xShug0/audio.cpp/pull/490).
- [@francescobozzo](https://github.com/francescobozzo) for Nix ROCm/HIP backend support, selectable model targets, and Nix CI/package fixes in [#162](https://github.com/0xShug0/audio.cpp/pull/162), [#163](https://github.com/0xShug0/audio.cpp/pull/163), and [#172](https://github.com/0xShug0/audio.cpp/pull/172).
- [@patrickvonplaten](https://github.com/patrickvonplaten) for Metal backend fixes, Voxtral Realtime streaming speedups, live audio streaming from stdin, and incremental transcript deltas in [#102](https://github.com/0xShug0/audio.cpp/pull/102), [#116](https://github.com/0xShug0/audio.cpp/pull/116), [#118](https://github.com/0xShug0/audio.cpp/pull/118), and [#127](https://github.com/0xShug0/audio.cpp/pull/127).
- [@dleiferives](https://github.com/dleiferives) for Parakeet-TDT 0.6B v3 ASR support and follow-up standalone GGUF validation/docs in [#111](https://github.com/0xShug0/audio.cpp/pull/111) and [#139](https://github.com/0xShug0/audio.cpp/pull/139).
- [@JanWerder](https://github.com/JanWerder) for adding Inflect Micro v2 and Nano v2 TTS support in [#125](https://github.com/0xShug0/audio.cpp/pull/125).
- [@chikosan](https://github.com/chikosan) for hardening safetensors parsing and bounding allocations sized by attacker-controlled file and header fields in [#138](https://github.com/0xShug0/audio.cpp/pull/138) and [#143](https://github.com/0xShug0/audio.cpp/pull/143).
- [@LauraGPT](https://github.com/LauraGPT) for adding Fun-ASR-Nano offline ASR, fixing the Linux build script executable bit, and bringing SenseVoice-Small offline/streaming ASR into the community model surface with Jason Chen and FunASR Ops in [#155](https://github.com/0xShug0/audio.cpp/pull/155), [#156](https://github.com/0xShug0/audio.cpp/pull/156), and [#219](https://github.com/0xShug0/audio.cpp/pull/219).
- [@liuzl](https://github.com/liuzl) for speeding up Metal `conv_transpose_1d` dispatch in [#149](https://github.com/0xShug0/audio.cpp/pull/149).
- [@JayDataEngineer](https://github.com/JayDataEngineer) for fixing PocketTTS `clone_audio_path` option typing in [#147](https://github.com/0xShug0/audio.cpp/pull/147).
- [@jasonchen31](https://github.com/jasonchen31) for adding server-side voice library folder support for name-based voice cloning, helping land SenseVoice-Small, fixing crashes on binaries built for older GPUs, adding Audio8 TTS, and fixing VoxCPM1 WebUI download/Yue language handling in [#191](https://github.com/0xShug0/audio.cpp/pull/191), [#219](https://github.com/0xShug0/audio.cpp/pull/219), [#240](https://github.com/0xShug0/audio.cpp/pull/240), [#333](https://github.com/0xShug0/audio.cpp/pull/333), and [#424](https://github.com/0xShug0/audio.cpp/pull/424). Additional work includes the VoxCPM1 model port in [#256](https://github.com/0xShug0/audio.cpp/pull/256); ZipVoice streaming and WebUI clone-reference transcript handling in [#692](https://github.com/0xShug0/audio.cpp/pull/692), [#693](https://github.com/0xShug0/audio.cpp/pull/693).
- [@mirek190](https://github.com/mirek190) for the native WebUI follow-up work around model management, package handling, request controls, package labels, new GGUF package surfacing, CUDA BF16 cuBLAS output on Ampere, MiniMax-H3 WebUI polish, retiring the legacy Python WebUI, adding the reusable native model package manager, clearing stale WebUI service workers, clarifying ACE-Step GGUF package labels, documenting server host/port options, fixing Qwen compact-logits graph reuse, accelerating MiraTTS on CPU/Vulkan, adding Kokoro multilingual GGUF synthesis and CPU optimization, and sharing the eSpeak-ng phonemizer across model frontends in [#199](https://github.com/0xShug0/audio.cpp/pull/199), [#206](https://github.com/0xShug0/audio.cpp/pull/206), [#207](https://github.com/0xShug0/audio.cpp/pull/207), [#208](https://github.com/0xShug0/audio.cpp/pull/208), [#211](https://github.com/0xShug0/audio.cpp/pull/211), [#213](https://github.com/0xShug0/audio.cpp/pull/213), [#229](https://github.com/0xShug0/audio.cpp/pull/229), [#230](https://github.com/0xShug0/audio.cpp/pull/230), [#257](https://github.com/0xShug0/audio.cpp/pull/257), [#258](https://github.com/0xShug0/audio.cpp/pull/258), [#381](https://github.com/0xShug0/audio.cpp/pull/381), [#426](https://github.com/0xShug0/audio.cpp/pull/426), [#491](https://github.com/0xShug0/audio.cpp/pull/491), [#496](https://github.com/0xShug0/audio.cpp/pull/496), and [#502](https://github.com/0xShug0/audio.cpp/pull/502). Additional work includes the initial embedded Svelte WebUI and the MiraTTS model port in [#185](https://github.com/0xShug0/audio.cpp/pull/185) and [#395](https://github.com/0xShug0/audio.cpp/pull/395); optimizing CUDA transpose-convolution taps in [#801](https://github.com/0xShug0/audio.cpp/pull/801), add Phonon-2 hotwords, streaming, and timestamps in [#863](https://github.com/0xShug0/audio.cpp/pull/863), and reduce Parakeet Vulkan decoder transfers and encoder allocation peaks in [#869](https://github.com/0xShug0/audio.cpp/pull/869).
- [@nikich340](https://github.com/nikich340) for adding explicit server model unloading support in [#197](https://github.com/0xShug0/audio.cpp/pull/197).
- [@utsl42](https://github.com/utsl42) for fixing CUDA linking on NixOS in [#214](https://github.com/0xShug0/audio.cpp/pull/214).
- [@yegorius](https://github.com/yegorius) for fixing PocketTTS handling in the model manager in [#205](https://github.com/0xShug0/audio.cpp/pull/205).
- [@noctrex](https://github.com/noctrex) for fixing the Windows build under MSYS2 / MinGW GCC 16 in [#135](https://github.com/0xShug0/audio.cpp/pull/135).
- [@panw3i](https://github.com/panw3i) for fixing PowerShell parse errors in `build_windows.ps1` in [#110](https://github.com/0xShug0/audio.cpp/pull/110).
- [@robotokpro](https://github.com/robotokpro) for fixing WebUI model download handling after `list_hf_files` began returning 3-tuples in [#106](https://github.com/0xShug0/audio.cpp/pull/106).
- [@jamesweiym-ops](https://github.com/jamesweiym-ops) for supporting TheRock LLVM layout in the Windows HIP build script in [#160](https://github.com/0xShug0/audio.cpp/pull/160).
- [@Blakeolson21](https://github.com/Blakeolson21) for documenting macOS CPU-only builds and fixing HIP backend classification in tests in [#237](https://github.com/0xShug0/audio.cpp/pull/237) and [#238](https://github.com/0xShug0/audio.cpp/pull/238).
- [@odest](https://github.com/odest) for adding Vulkan backend support to Windows builds in [#242](https://github.com/0xShug0/audio.cpp/pull/242).
- [@JoeMattie](https://github.com/JoeMattie) for bringing up MiniMax Music3, fixing BF16 convolution issues, repairing conversion, wiring native WebUI support, and honoring required request options in Studio in [#243](https://github.com/0xShug0/audio.cpp/pull/243) and [#245](https://github.com/0xShug0/audio.cpp/pull/245).
- [@Orion-zhen](https://github.com/Orion-zhen) for adding `--voice-dir` parsing to the server CLI and fixing a HIP build error in [#246](https://github.com/0xShug0/audio.cpp/pull/246) and [#486](https://github.com/0xShug0/audio.cpp/pull/486).
- [@derekja](https://github.com/derekja) for multipart ASR recognition prompts, clearer large-request errors, ASR graph allocation fixes, and CUDA graph-cache cleanup for concurrent ASR pressure in [#262](https://github.com/0xShug0/audio.cpp/pull/262), [#264](https://github.com/0xShug0/audio.cpp/pull/264), [#276](https://github.com/0xShug0/audio.cpp/pull/276), and [#293](https://github.com/0xShug0/audio.cpp/pull/293).
- [@kawshikbuet17](https://github.com/kawshikbuet17) for adding OmniVoice weight-type benchmark coverage, validation reports, and Python-to-C++ tests in [#269](https://github.com/0xShug0/audio.cpp/pull/269).
- [@tareko](https://github.com/tareko) for adding F5-TTS community scaffolding with Habibi Arabic aliases in [#275](https://github.com/0xShug0/audio.cpp/pull/275).
- [@jrohde](https://github.com/jrohde) for adding the MOSS-VoiceGenerator community model in [#278](https://github.com/0xShug0/audio.cpp/pull/278); the model-weight license overview in [#648](https://github.com/0xShug0/audio.cpp/pull/648).
- [@LysanderdeJong](https://github.com/LysanderdeJong) for adding the MMS-300M-1130 forced aligner community model, Sortformer v2.1 streaming diarization, the Orukeet r3 Parakeet-TDT weight variant, and VibeVoice ASR Streaming 7B performance work in [#279](https://github.com/0xShug0/audio.cpp/pull/279), [#493](https://github.com/0xShug0/audio.cpp/pull/493), [#523](https://github.com/0xShug0/audio.cpp/pull/523), and [#526](https://github.com/0xShug0/audio.cpp/pull/526); Nemotron speaker-tagged ASR, spec migration, speaker-probability export, and diarization parity improvements in [#677](https://github.com/0xShug0/audio.cpp/pull/677), [#681](https://github.com/0xShug0/audio.cpp/pull/681), [#685](https://github.com/0xShug0/audio.cpp/pull/685), [#686](https://github.com/0xShug0/audio.cpp/pull/686).
- [@drzsdrtfg](https://github.com/drzsdrtfg) for the tag-driven prebuilt release pipeline, Supertonic voice-preset request-option fix, Soprano TTS community model, Soprano maintainer metadata correction, and Qwen cached-graph prefill input fix in [#286](https://github.com/0xShug0/audio.cpp/pull/286), [#302](https://github.com/0xShug0/audio.cpp/pull/302), [#323](https://github.com/0xShug0/audio.cpp/pull/323), [#331](https://github.com/0xShug0/audio.cpp/pull/331), and [#451](https://github.com/0xShug0/audio.cpp/pull/451). Additional work includes prebuilt binary documentation and Soprano text normalization in [#342](https://github.com/0xShug0/audio.cpp/pull/342) and [#355](https://github.com/0xShug0/audio.cpp/pull/355); adding opt-in model-load progress logging in [#840](https://github.com/0xShug0/audio.cpp/pull/840).
- [@Hi5808](https://github.com/Hi5808) for documenting Jetson Orin bring-up and correcting native architecture wording in [#288](https://github.com/0xShug0/audio.cpp/pull/288).
- [@SelfRef](https://github.com/SelfRef) for making embedded WebUI work behind a path-prefix reverse proxy and adding server model LRU controls in [#297](https://github.com/0xShug0/audio.cpp/pull/297) and [#298](https://github.com/0xShug0/audio.cpp/pull/298).
- [@bjhengen](https://github.com/bjhengen) for dropping out-of-span chunk speech metadata instead of aborting the whole run in [#301](https://github.com/0xShug0/audio.cpp/pull/301).
- [@gqf2008](https://github.com/gqf2008) for adding server idle unload and pre-load memory guard behavior, tightening indeterminate-footprint handling, adding Audio8 ASR, improving default CJK text chunking, and bringing the unit test suite into Linux/macOS/Windows CI in [#306](https://github.com/0xShug0/audio.cpp/pull/306), [#308](https://github.com/0xShug0/audio.cpp/pull/308), [#337](https://github.com/0xShug0/audio.cpp/pull/337), [#441](https://github.com/0xShug0/audio.cpp/pull/441), and [#442](https://github.com/0xShug0/audio.cpp/pull/442). Additional work includes the shared greedy Qwen decoder runtime and Qwen BPE bundle helpers in [#555](https://github.com/0xShug0/audio.cpp/pull/555); Audio8 TTS Falcon-H1/Mamba2 support and codec/AR optimizations in [#444](https://github.com/0xShug0/audio.cpp/pull/444); Breeze BF16 activation rounding on Metal and Bash 3.2 build-script fixes in [#554](https://github.com/0xShug0/audio.cpp/pull/554), [#602](https://github.com/0xShug0/audio.cpp/pull/602).
- [@ampersandru](https://github.com/ampersandru) for adding IBM Granite Speech 5.0 470M TurboCTC ASR as a community model in [#311](https://github.com/0xShug0/audio.cpp/pull/311).
- [@iamwavecut](https://github.com/iamwavecut) for MiniMax Music3 performance work around native RoPE/SwiGLU lowering, opt-in CFG reuse, chunk hop support, batched ensemble takes, and Q4_K depth decoder support in [#321](https://github.com/0xShug0/audio.cpp/pull/321). Additional work includes initializing CUDA MMQ stream-k staging memory in [#320](https://github.com/0xShug0/audio.cpp/pull/320).
- [@XythQ](https://github.com/XythQ) for resolving model contracts once per loaded model instead of per request in [#328](https://github.com/0xShug0/audio.cpp/pull/328).
- [@pannagaps](https://github.com/pannagaps) for adding Chatterbox Turbo TTS as a community model in [#394](https://github.com/0xShug0/audio.cpp/pull/394).
- [@gsaon](https://github.com/gsaon) for fixing Granite Speech ASR mel filterbank construction on the continuous frequency axis in [#384](https://github.com/0xShug0/audio.cpp/pull/384).
- [@reezex0-ux](https://github.com/reezex0-ux) for adding the HIP Fast-AR top-k sampler path for Fish Audio and keeping Fast-AR inputs on the HIP stream in [#386](https://github.com/0xShug0/audio.cpp/pull/386) and [#500](https://github.com/0xShug0/audio.cpp/pull/500).
- [@feng19](https://github.com/feng19) for adding `HF_ENDPOINT` mirror support to model downloads in [#397](https://github.com/0xShug0/audio.cpp/pull/397).
- [@CryptVenture](https://github.com/CryptVenture) for adding richer WAV output options, correcting the shared text chunk mode preset, accepting the MOSS VoiceGenerator instruction spelling used by the speech route, and forwarding `language` only when a model contract accepts it in [#358](https://github.com/0xShug0/audio.cpp/pull/358), [#370](https://github.com/0xShug0/audio.cpp/pull/370), [#371](https://github.com/0xShug0/audio.cpp/pull/371), and [#400](https://github.com/0xShug0/audio.cpp/pull/400). Additional work includes the detailed transcription endpoint in [#401](https://github.com/0xShug0/audio.cpp/pull/401).
- [@voidash](https://github.com/voidash) for adding the sanoTTS voice family and expanding it with eleven more language voices plus acoustic calibration adapter support in [#449](https://github.com/0xShug0/audio.cpp/pull/449) and [#495](https://github.com/0xShug0/audio.cpp/pull/495).
- [@XsquirrelC](https://github.com/XsquirrelC) for adding additive INT8/ternary GGML types, fused CPU ops, and the VibeASR INT8/ternary ASR pipeline in [#447](https://github.com/0xShug0/audio.cpp/pull/447) and [#448](https://github.com/0xShug0/audio.cpp/pull/448).
- [@niksedk](https://github.com/niksedk) for fixing Higgs Audio TTS codec decode seams by giving the codec decoder right-hand context at the end of the stream in [#454](https://github.com/0xShug0/audio.cpp/pull/454); CLI WAV sample-format selection in [#591](https://github.com/0xShug0/audio.cpp/pull/591).
- [@Th-Underscore](https://github.com/Th-Underscore) for adding the `Ctrl` variant to the BreezeTTS model entry in [#463](https://github.com/0xShug0/audio.cpp/pull/463). Additional work includes attention compatibility for GPUs without usable flash MMA kernels in [#423](https://github.com/0xShug0/audio.cpp/pull/423); IQ GGUF tensor storage types, Drama3-small architecture support, and opt-in chunked Fish DAC reference encoding in [#958](https://github.com/0xShug0/audio.cpp/pull/958), [#959](https://github.com/0xShug0/audio.cpp/pull/959), [#960](https://github.com/0xShug0/audio.cpp/pull/960).
- [@DrewThomasson](https://github.com/DrewThomasson) for adding the one-click Colab WebUI notebook and CUDA T4 release build path in [#501](https://github.com/0xShug0/audio.cpp/pull/501).
- [@Anai-Guo](https://github.com/Anai-Guo) for fixing the Voxtral Realtime warmbench path to use the warmup case streaming flag in [#506](https://github.com/0xShug0/audio.cpp/pull/506).
- [@dpmm99](https://github.com/dpmm99) for fixing the configured-model Yue2 UI parameter surface in [#509](https://github.com/0xShug0/audio.cpp/pull/509).
- [@weidu-tpvision](https://github.com/weidu-tpvision) for preventing 32-bit `size_t` overflow in HiFT vocoder and model configuration allocation paths in [#512](https://github.com/0xShug0/audio.cpp/pull/512) and [#513](https://github.com/0xShug0/audio.cpp/pull/513). Additional work includes smaller default model contexts on 32-bit targets in [#547](https://github.com/0xShug0/audio.cpp/pull/547); the CLAP mel-projection layout fix in [#593](https://github.com/0xShug0/audio.cpp/pull/593).
- [@SmilingWolf](https://github.com/SmilingWolf) for making PocketTTS reference audio fields visible again in [#528](https://github.com/0xShug0/audio.cpp/pull/528).
- [@christopherthompson81](https://github.com/christopherthompson81) for the opt-in C ABI, Parakeet encoder graph sizing, sentence-aware WebUI text splitting, shared task vocabulary, safetensors package requirements, and incremental partial-text publishing in [#530](https://github.com/0xShug0/audio.cpp/pull/530), [#536](https://github.com/0xShug0/audio.cpp/pull/536), [#539](https://github.com/0xShug0/audio.cpp/pull/539), [#544](https://github.com/0xShug0/audio.cpp/pull/544), [#550](https://github.com/0xShug0/audio.cpp/pull/550), [#552](https://github.com/0xShug0/audio.cpp/pull/552); MOSS-TTS v1.5 and MOSS-TTSD model support, shared runtimes, memory improvements, codec attention-window correction, and spec/timing updates in [#631](https://github.com/0xShug0/audio.cpp/pull/631), [#652](https://github.com/0xShug0/audio.cpp/pull/652), [#654](https://github.com/0xShug0/audio.cpp/pull/654), [#664](https://github.com/0xShug0/audio.cpp/pull/664), [#676](https://github.com/0xShug0/audio.cpp/pull/676), [#687](https://github.com/0xShug0/audio.cpp/pull/687), [#690](https://github.com/0xShug0/audio.cpp/pull/690); Kokoro phoneme handling, supplied phoneme input, and word timings in [#564](https://github.com/0xShug0/audio.cpp/pull/564), [#565](https://github.com/0xShug0/audio.cpp/pull/565), [#577](https://github.com/0xShug0/audio.cpp/pull/577), [#626](https://github.com/0xShug0/audio.cpp/pull/626); VibeVoice ASR Streaming 1.5B support, Voxtral UTF-8 streaming boundaries, and ASR encoder graph sizing in [#613](https://github.com/0xShug0/audio.cpp/pull/613), [#619](https://github.com/0xShug0/audio.cpp/pull/619), [#684](https://github.com/0xShug0/audio.cpp/pull/684); GGUF sidecar preservation, package verification, parallel quantization, and C API list-valued options in [#566](https://github.com/0xShug0/audio.cpp/pull/566), [#579](https://github.com/0xShug0/audio.cpp/pull/579), [#649](https://github.com/0xShug0/audio.cpp/pull/649); Vulkan shader compilation retries, opt-in ccache, reproducible WebUI bundles, and CI test tooling in [#540](https://github.com/0xShug0/audio.cpp/pull/540), [#546](https://github.com/0xShug0/audio.cpp/pull/546), [#576](https://github.com/0xShug0/audio.cpp/pull/576), [#627](https://github.com/0xShug0/audio.cpp/pull/627), [#634](https://github.com/0xShug0/audio.cpp/pull/634); YuE2 NAR LoRA controls and the AudioCpp-Bindings project listing in [#614](https://github.com/0xShug0/audio.cpp/pull/614), [#665](https://github.com/0xShug0/audio.cpp/pull/665).
- [@mwzkhalil](https://github.com/mwzkhalil) for improving Higgs Audio TTS token-limit error guidance in [#522](https://github.com/0xShug0/audio.cpp/pull/522).
- [@AdityaAWaghmare](https://github.com/AdityaAWaghmare) for fixing Vulkan COL2IM_1D dispatch for large tensors in [#542](https://github.com/0xShug0/audio.cpp/pull/542).
- [@Brioch](https://github.com/Brioch) for adding Sopro V2 Turbo with offline synthesis and streaming in [#353](https://github.com/0xShug0/audio.cpp/pull/353).
- [@FraserHum](https://github.com/FraserHum) for preallocating BreezeTTS depth-projection and generation staging buffers in [#348](https://github.com/0xShug0/audio.cpp/pull/348).
- [@huyndao](https://github.com/huyndao) for fixing missing CUDA-to-HIP stream-priority API mappings in [#346](https://github.com/0xShug0/audio.cpp/pull/346).
- [@avionicharshit-byte](https://github.com/avionicharshit-byte) for rejecting unknown CLI options instead of silently ignoring them in [#339](https://github.com/0xShug0/audio.cpp/pull/339).
- [@davidxifeng](https://github.com/davidxifeng) for adding ZipVoice TTS and Confucius4 R2T2 streaming ASR in [#599](https://github.com/0xShug0/audio.cpp/pull/599), [#604](https://github.com/0xShug0/audio.cpp/pull/604).
- [@SMGoro](https://github.com/SMGoro) for adding HTDemucs six-stem source separation in [#694](https://github.com/0xShug0/audio.cpp/pull/694).
- [@pnnbao97](https://github.com/pnnbao97) for improving VieNeu-TTS v3 Turbo reference-code conditioning, sampling parity, GGUF packages, Vietnamese text input, chunking, pauses, and reusable enrollment artifacts in [#647](https://github.com/0xShug0/audio.cpp/pull/647), [#658](https://github.com/0xShug0/audio.cpp/pull/658).
- [@lojack5](https://github.com/lojack5) for Echo-TTS memory optimizations and compatibility with older embedded model specs in [#731](https://github.com/0xShug0/audio.cpp/pull/731), [#736](https://github.com/0xShug0/audio.cpp/pull/736); opt-in joint CFG for Echo-TTS in [#773](https://github.com/0xShug0/audio.cpp/pull/773).
- [@engival](https://github.com/engival) for YuE2 eager and tiled NAR attention, decode-cache initialization, semantic-token export and continuation, staged generation, and process/context memory metrics in [#557](https://github.com/0xShug0/audio.cpp/pull/557), [#560](https://github.com/0xShug0/audio.cpp/pull/560), [#635](https://github.com/0xShug0/audio.cpp/pull/635), [#636](https://github.com/0xShug0/audio.cpp/pull/636), [#641](https://github.com/0xShug0/audio.cpp/pull/641), [#642](https://github.com/0xShug0/audio.cpp/pull/642), [#688](https://github.com/0xShug0/audio.cpp/pull/688).
- [@bvisible](https://github.com/bvisible) for PocketTTS flow-depth variant loading and reference-aligned, UTF-8-aware text chunking in [#670](https://github.com/0xShug0/audio.cpp/pull/670), [#671](https://github.com/0xShug0/audio.cpp/pull/671).
- [@mduka](https://github.com/mduka) for correcting VoxCPM2 streaming decoder context and enabling stateful VoxCPM1 streaming decode on Vulkan in [#622](https://github.com/0xShug0/audio.cpp/pull/622), [#623](https://github.com/0xShug0/audio.cpp/pull/623), [#624](https://github.com/0xShug0/audio.cpp/pull/624).
- [@scriptease](https://github.com/scriptease) for incremental Confucius4 R2T2 streaming and prompt support on the live transcription route in [#618](https://github.com/0xShug0/audio.cpp/pull/618), [#696](https://github.com/0xShug0/audio.cpp/pull/696); restoring executable permissions on release tarball binaries and document R2T2's trailing-silence requirement in [#754](https://github.com/0xShug0/audio.cpp/pull/754), [#770](https://github.com/0xShug0/audio.cpp/pull/770).
- [@lkarlslund](https://github.com/lkarlslund) for RoFormer HIP FP32 attention optimization with contiguous operands in [#573](https://github.com/0xShug0/audio.cpp/pull/573).
- [@chbgdn](https://github.com/chbgdn) for exporting generated YuE2 ABC plans as score artifacts in [#561](https://github.com/0xShug0/audio.cpp/pull/561).
- [@otto-seed28](https://github.com/otto-seed28) for native CUDA SASS release coverage for Turing GPUs in [#559](https://github.com/0xShug0/audio.cpp/pull/559).
- [@hdcola](https://github.com/hdcola) for fixing Confucius4 R2T2 final streaming flushes with no tokens in [#712](https://github.com/0xShug0/audio.cpp/pull/712).
- [@Faazil-jpg](https://github.com/Faazil-jpg) for making Moonshine reject input beyond its positional-embedding limit instead of crashing in [#662](https://github.com/0xShug0/audio.cpp/pull/662).
- [@DivyamTalwar](https://github.com/DivyamTalwar) for escaping control characters in workflow file-sink JSON in [#640](https://github.com/0xShug0/audio.cpp/pull/640).
- [@NairoDorian](https://github.com/NairoDorian) for adding a community Q4_K_M GGUF package for Confucius4 R2T2 in [#702](https://github.com/0xShug0/audio.cpp/pull/702).
- [@asfdrwe](https://github.com/asfdrwe) for the Japanese WebUI translation in [#719](https://github.com/0xShug0/audio.cpp/pull/719) and Irodori-TTS streaming in [#881](https://github.com/0xShug0/audio.cpp/pull/881).
- [@dasilva333](https://github.com/dasilva333) for the Fish Audio S2 Pro quantization recipe and ecosystem integration documentation in [#588](https://github.com/0xShug0/audio.cpp/pull/588).
- [@dongfangzhizhu](https://github.com/dongfangzhizhu) for adding AudioCpp.NET to the project listings in [#585](https://github.com/0xShug0/audio.cpp/pull/585).
- [@leemysw](https://github.com/leemysw) for adding yovoice to the project listings in [#587](https://github.com/0xShug0/audio.cpp/pull/587).
- [@inikolax](https://github.com/inikolax) for adding Remiqora to the project listings in [#695](https://github.com/0xShug0/audio.cpp/pull/695).
- [@ykhrustalev](https://github.com/ykhrustalev) for LFM2.5-Audio English and Japanese ASR, TTS, and speech-to-speech support in [#758](https://github.com/0xShug0/audio.cpp/pull/758), [#768](https://github.com/0xShug0/audio.cpp/pull/768), [#798](https://github.com/0xShug0/audio.cpp/pull/798); UTF-8 transcript boundaries, encoder graph reuse, task-specific decode-cache sizing, input/package diagnostics, and speech-to-speech sampling controls in [#765](https://github.com/0xShug0/audio.cpp/pull/765), [#766](https://github.com/0xShug0/audio.cpp/pull/766), [#813](https://github.com/0xShug0/audio.cpp/pull/813), [#820](https://github.com/0xShug0/audio.cpp/pull/820), [#827](https://github.com/0xShug0/audio.cpp/pull/827); optional text output and input handling on the live speech endpoint, and the LFM2-Audio WebUI input fix in [#805](https://github.com/0xShug0/audio.cpp/pull/805), [#822](https://github.com/0xShug0/audio.cpp/pull/822), [#828](https://github.com/0xShug0/audio.cpp/pull/828); adding repacked CPU kernels, cross-turn prefill/encoder reuse, and multiple conversation caches in [#834](https://github.com/0xShug0/audio.cpp/pull/834), [#845](https://github.com/0xShug0/audio.cpp/pull/845), [#853](https://github.com/0xShug0/audio.cpp/pull/853); client-carried S2S history and the WebUI conversation panel in [#838](https://github.com/0xShug0/audio.cpp/pull/838), [#857](https://github.com/0xShug0/audio.cpp/pull/857), [#864](https://github.com/0xShug0/audio.cpp/pull/864); request input artifacts and SSE task events in [#844](https://github.com/0xShug0/audio.cpp/pull/844), [#849](https://github.com/0xShug0/audio.cpp/pull/849); and a CUDA shared-memory reduction race fix in [#837](https://github.com/0xShug0/audio.cpp/pull/837).
- [@dignome](https://github.com/dignome) for KittenTTS2 support and timing/implementation cleanup in [#776](https://github.com/0xShug0/audio.cpp/pull/776), [#781](https://github.com/0xShug0/audio.cpp/pull/781); Chatterbox S3 relative-shift and LayerNorm corrections in [#778](https://github.com/0xShug0/audio.cpp/pull/778); repeated-request fixes for Voxtral Realtime in [#793](https://github.com/0xShug0/audio.cpp/pull/793); updated Kitten TTS 2 checkpoints and BF16, Q8, and Q4 GGUF support in [#947](https://github.com/0xShug0/audio.cpp/pull/947).
- [@Zhuoxi2000](https://github.com/Zhuoxi2000) for bounding safetensors metadata nesting and correcting PyTorch tensor storage-offset handling in [#753](https://github.com/0xShug0/audio.cpp/pull/753), [#757](https://github.com/0xShug0/audio.cpp/pull/757).
- [@MohammedAly22](https://github.com/MohammedAly22) for Egyptian Arabic VoiceTut-TTS and QwenCleo-ASR packages, documentation, and Qwen3-ASR tied-weight loading support in [#780](https://github.com/0xShug0/audio.cpp/pull/780).
- [@mister2d](https://github.com/mister2d) for fixing WebUI execution of bundled VAD models in [#782](https://github.com/0xShug0/audio.cpp/pull/782).
- [@Navier8](https://github.com/Navier8) for exposing spec-backed model parameters through `GET /v1/models?include_params=true` in [#787](https://github.com/0xShug0/audio.cpp/pull/787).
- [@zeropointnine](https://github.com/zeropointnine) for adding tts-audiobook-tool to the project listings in [#788](https://github.com/0xShug0/audio.cpp/pull/788).
- [@ha466](https://github.com/ha466) for improving CUDA 12 library handling in the Colab notebook in [#795](https://github.com/0xShug0/audio.cpp/pull/795).
- [@trappedinspacetime](https://github.com/trappedinspacetime) for enabling CLI output to FIFOs and character devices such as `/dev/stdout` in [#796](https://github.com/0xShug0/audio.cpp/pull/796).
- [@juntaosun](https://github.com/juntaosun) for adding ComeCut to the project listings in [#804](https://github.com/0xShug0/audio.cpp/pull/804).
- [@wangguanghua2099](https://github.com/wangguanghua2099) for documenting YuE2 long-form host-memory exhaustion with reproduction logs and reduced-arena guidance in [#816](https://github.com/0xShug0/audio.cpp/pull/816).
- [@samuel-vitorino](https://github.com/samuel-vitorino) for Sopro v2.2.0 parity improvements, chunked streaming, and opt-in voice caching in [#818](https://github.com/0xShug0/audio.cpp/pull/818).
- [@BumpyClock](https://github.com/BumpyClock) for the experimental Whistle ASR model and its GGML decoder step graph in [#824](https://github.com/0xShug0/audio.cpp/pull/824), [#843](https://github.com/0xShug0/audio.cpp/pull/843).
- [@KKTTSPJ](https://github.com/KKTTSPJ) for Irodori-TTS Speaker Inversion embeddings, duration-predictor-free checkpoints, opt-in chunked codec processing, and reference-audio length limits in [#829](https://github.com/0xShug0/audio.cpp/pull/829), [#832](https://github.com/0xShug0/audio.cpp/pull/832), [#835](https://github.com/0xShug0/audio.cpp/pull/835), [#854](https://github.com/0xShug0/audio.cpp/pull/854); server voice discovery and previews in the WebUI, voice-preset request options, shared session defaults, and consistent handling of server-configured models in [#941](https://github.com/0xShug0/audio.cpp/pull/941), [#942](https://github.com/0xShug0/audio.cpp/pull/942), [#943](https://github.com/0xShug0/audio.cpp/pull/943), [#944](https://github.com/0xShug0/audio.cpp/pull/944), [#971](https://github.com/0xShug0/audio.cpp/pull/971), [#972](https://github.com/0xShug0/audio.cpp/pull/972).
- [@tarelerulz](https://github.com/tarelerulz) for the opt-in ARM NEON CPU decoder for sanoTTS nano voices in [#860](https://github.com/0xShug0/audio.cpp/pull/860).
- [@estebanstifli](https://github.com/estebanstifli) for adding OpenDub to the project listings in [#868](https://github.com/0xShug0/audio.cpp/pull/868).
- [@K4viar](https://github.com/K4viar) for opt-in exact attention on Higgs Audio cached-reference suffixes in [#874](https://github.com/0xShug0/audio.cpp/pull/874).
- [@SkYContact](https://github.com/SkYContact) for MOSS-TTS-Nano incremental KV-cached decoding, encoded speaker-reference reuse, and quantized GPU prefill improvements in [#878](https://github.com/0xShug0/audio.cpp/pull/878), [#885](https://github.com/0xShug0/audio.cpp/pull/885).
- [@jianchang512](https://github.com/jianchang512) for adding pyVideoTrans to the project listings in [#889](https://github.com/0xShug0/audio.cpp/pull/889).
- [@wOvAN](https://github.com/wOvAN) for the shared RKNN engine and opt-in Rockchip NPU decoder/generator paths for Qwen3 TTS, Piper, and Kokoro in [#900](https://github.com/0xShug0/audio.cpp/pull/900), [#896](https://github.com/0xShug0/audio.cpp/pull/896), [#897](https://github.com/0xShug0/audio.cpp/pull/897), [#904](https://github.com/0xShug0/audio.cpp/pull/904); Piper graph-arena sizing for long unchunked latents in [#911](https://github.com/0xShug0/audio.cpp/pull/911); Qwen3-TTS single-pass chunk decoding, streaming RKNN decoding, Piper execution plans and duration-encoder ladders, and the Inflect v2 RKNN decoder in [#910](https://github.com/0xShug0/audio.cpp/pull/910), [#913](https://github.com/0xShug0/audio.cpp/pull/913), [#915](https://github.com/0xShug0/audio.cpp/pull/915), [#992](https://github.com/0xShug0/audio.cpp/pull/992), [#994](https://github.com/0xShug0/audio.cpp/pull/994); F5-TTS graph-pool limits, staged CUDA constant cleanup, unused weight-store removal, and request-sized VoxCPM2 step graphs in [#924](https://github.com/0xShug0/audio.cpp/pull/924), [#950](https://github.com/0xShug0/audio.cpp/pull/950), [#953](https://github.com/0xShug0/audio.cpp/pull/953), [#956](https://github.com/0xShug0/audio.cpp/pull/956); model-set-aware test builds, test source linkage, Inflect conversion, and Windows-safe embedded spec literals in [#925](https://github.com/0xShug0/audio.cpp/pull/925), [#932](https://github.com/0xShug0/audio.cpp/pull/932), [#933](https://github.com/0xShug0/audio.cpp/pull/933), [#934](https://github.com/0xShug0/audio.cpp/pull/934); RK3588 documentation and CosyVoice3 NPU flow inference in [#945](https://github.com/0xShug0/audio.cpp/pull/945), [#946](https://github.com/0xShug0/audio.cpp/pull/946), [#949](https://github.com/0xShug0/audio.cpp/pull/949), [#951](https://github.com/0xShug0/audio.cpp/pull/951); shared RKNN residency accounting, converter helpers, engine resolution, memory guards, sliced-graph loading, and sidecar diagnostics in [#957](https://github.com/0xShug0/audio.cpp/pull/957), [#961](https://github.com/0xShug0/audio.cpp/pull/961), [#963](https://github.com/0xShug0/audio.cpp/pull/963), [#964](https://github.com/0xShug0/audio.cpp/pull/964), [#970](https://github.com/0xShug0/audio.cpp/pull/970), [#975](https://github.com/0xShug0/audio.cpp/pull/975), [#983](https://github.com/0xShug0/audio.cpp/pull/983), [#989](https://github.com/0xShug0/audio.cpp/pull/989), [#990](https://github.com/0xShug0/audio.cpp/pull/990); prebuilt RKNN packages, sidecar loader tests, and the Rockchip NPU Docker image in [#991](https://github.com/0xShug0/audio.cpp/pull/991), [#995](https://github.com/0xShug0/audio.cpp/pull/995), [#1002](https://github.com/0xShug0/audio.cpp/pull/1002).
- [@bradmurray-dt](https://github.com/bradmurray-dt) for CTC word timestamps in Granite Speech 5.0 TurboCTC in [#903](https://github.com/0xShug0/audio.cpp/pull/903); multilingual Granite TurboCTC checkpoint support in [#1035](https://github.com/0xShug0/audio.cpp/pull/1035).
- [@zlatkoviz-png](https://github.com/zlatkoviz-png) for correcting MioTTS repetition penalties to apply once per distinct token in [#906](https://github.com/0xShug0/audio.cpp/pull/906).
- [@Etherll](https://github.com/Etherll) for the native C++ Earshot VAD port and support for built-in VAD families in ASR chunking in [#1095](https://github.com/0xShug0/audio.cpp/pull/1095), [#1096](https://github.com/0xShug0/audio.cpp/pull/1096).
- [@Fishballllllll](https://github.com/Fishballllllll) for opt-in BreezeTTS CUDA depth-graph fusion, BF16-rounding kernel fusions, and batched CFG decoding in [#1090](https://github.com/0xShug0/audio.cpp/pull/1090), [#1091](https://github.com/0xShug0/audio.cpp/pull/1091), [#1092](https://github.com/0xShug0/audio.cpp/pull/1092).
- [@HamSanders](https://github.com/HamSanders) for exposing the build version and Git commit through `/health` in [#1097](https://github.com/0xShug0/audio.cpp/pull/1097).
- [@JarodMica](https://github.com/JarodMica) for the initial Fish Audio Drama3 community model port in [#916](https://github.com/0xShug0/audio.cpp/pull/916).
- [@JoseConseco](https://github.com/JoseConseco) for accelerating GGUF numeric-array metadata loading with bulk reads in [#1072](https://github.com/0xShug0/audio.cpp/pull/1072).
- [@Ramon-Balaguer](https://github.com/Ramon-Balaguer) for bounded host staging for HIP device-weight uploads in [#1070](https://github.com/0xShug0/audio.cpp/pull/1070).
- [@ZeroDot1](https://github.com/ZeroDot1) for ACE-Step planner attention dimensions and order-preserving non-finite-logit filtering for Higgs Audio in [#1026](https://github.com/0xShug0/audio.cpp/pull/1026), [#1032](https://github.com/0xShug0/audio.cpp/pull/1032), [#1038](https://github.com/0xShug0/audio.cpp/pull/1038); MOSS-Nano supplementary-CJK chunking, the VocalRender-Pro VoxCPM2 package, Ming3 audio-tail preservation, and parallel disk reads in [#1036](https://github.com/0xShug0/audio.cpp/pull/1036), [#1037](https://github.com/0xShug0/audio.cpp/pull/1037), [#1041](https://github.com/0xShug0/audio.cpp/pull/1041), [#1042](https://github.com/0xShug0/audio.cpp/pull/1042); the Arch Linux AMD installer, HIP VMM access/default fixes, and direct uploads for weight-normalization tensor views in [#1058](https://github.com/0xShug0/audio.cpp/pull/1058), [#1059](https://github.com/0xShug0/audio.cpp/pull/1059), [#1080](https://github.com/0xShug0/audio.cpp/pull/1080), [#1081](https://github.com/0xShug0/audio.cpp/pull/1081).
- [@apt-install-coffee](https://github.com/apt-install-coffee) for preserving parenthesized tags during text chunking in [#1000](https://github.com/0xShug0/audio.cpp/pull/1000).
- [@davencyw](https://github.com/davencyw) for opt-in plain RMS normalization for timestep embeddings and Gradium Phonon TTS on the PocketTTS runtime in [#1061](https://github.com/0xShug0/audio.cpp/pull/1061), [#1062](https://github.com/0xShug0/audio.cpp/pull/1062).
- [@ersensari](https://github.com/ersensari) for VoxCPM2 AudioVAE graph sizing, one-pass CPU Snake, and optional CPU decode overlap in [#981](https://github.com/0xShug0/audio.cpp/pull/981).
- [@hiroki-abe-58](https://github.com/hiroki-abe-58) for Ming3 language handling and long-text splitting in [#1020](https://github.com/0xShug0/audio.cpp/pull/1020), [#1021](https://github.com/0xShug0/audio.cpp/pull/1021), [#1034](https://github.com/0xShug0/audio.cpp/pull/1034); language-aware chunk sizes for Kokoro, MOSS-TTS-Nano, Irodori-TTS, FireRedTTS3, MagpieTTS, and MioTTS in [#1022](https://github.com/0xShug0/audio.cpp/pull/1022), [#1025](https://github.com/0xShug0/audio.cpp/pull/1025), [#1027](https://github.com/0xShug0/audio.cpp/pull/1027), [#1039](https://github.com/0xShug0/audio.cpp/pull/1039), [#1043](https://github.com/0xShug0/audio.cpp/pull/1043), [#1044](https://github.com/0xShug0/audio.cpp/pull/1044), [#1046](https://github.com/0xShug0/audio.cpp/pull/1046); splitting unpunctuated CJK runs at the text budget in [#1045](https://github.com/0xShug0/audio.cpp/pull/1045).
- [@kinhatula](https://github.com/kinhatula) for Vietnamese WebUI translations in [#839](https://github.com/0xShug0/audio.cpp/pull/839).
- [@railep](https://github.com/railep) for per-model backend and device selection in the server in [#973](https://github.com/0xShug0/audio.cpp/pull/973).
- [@simcop2387](https://github.com/simcop2387) for submodule URLs that work with HTTPS, SSH, and repository forks in [#923](https://github.com/0xShug0/audio.cpp/pull/923).
- [@whoreson](https://github.com/whoreson) for XTTS v2 support in [#876](https://github.com/0xShug0/audio.cpp/pull/876).

190
LICENSE Normal file
View file

@ -0,0 +1,190 @@
Copyright 2026 ShugoAI LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

70
Makefile.windows Normal file
View file

@ -0,0 +1,70 @@
POWERSHELL ?= powershell.exe
BUILD_SCRIPT := .\scripts\build_windows.ps1
BUILD_CMD := .\scripts\build_windows.cmd
THIS_MAKEFILE := $(lastword $(MAKEFILE_LIST))
PRESET ?= windows-cuda-release
TARGET ?= audiocpp_cli
JOBS ?=
VS_INSTALL ?=
NATIVE_CPU ?=
.PHONY: all build configure cpu cpu-release cuda cuda-debug cuda-release vulkan vulkan-debug vulkan-release clean presets help
all: cuda
build:
$(POWERSHELL) -NoProfile -ExecutionPolicy Bypass -File "$(BUILD_SCRIPT)" -Preset "$(PRESET)" $(if $(TARGET),-Target "$(TARGET)",) $(if $(JOBS),-Jobs "$(JOBS)",) $(if $(NATIVE_CPU),-NativeCpu "$(NATIVE_CPU)",) $(if $(VS_INSTALL),-VsInstall "$(VS_INSTALL)",)
configure:
$(POWERSHELL) -NoProfile -ExecutionPolicy Bypass -File "$(BUILD_SCRIPT)" -Preset "$(PRESET)" -ConfigureOnly $(if $(NATIVE_CPU),-NativeCpu "$(NATIVE_CPU)",) $(if $(VS_INSTALL),-VsInstall "$(VS_INSTALL)",)
cpu:
$(MAKE) -f "$(THIS_MAKEFILE)" build PRESET=windows-cpu-release
cpu-release:
$(MAKE) -f "$(THIS_MAKEFILE)" build PRESET=windows-cpu-release
cuda:
$(MAKE) -f "$(THIS_MAKEFILE)" build PRESET=windows-cuda-release
cuda-debug:
$(MAKE) -f "$(THIS_MAKEFILE)" build PRESET=windows-cuda-debug
cuda-release:
$(MAKE) -f "$(THIS_MAKEFILE)" build PRESET=windows-cuda-release
vulkan:
$(MAKE) -f "$(THIS_MAKEFILE)" build PRESET=windows-vulkan-release
vulkan-debug:
$(MAKE) -f "$(THIS_MAKEFILE)" build PRESET=windows-vulkan-debug
vulkan-release:
$(MAKE) -f "$(THIS_MAKEFILE)" build PRESET=windows-vulkan-release
clean:
$(POWERSHELL) -NoProfile -ExecutionPolicy Bypass -File "$(BUILD_SCRIPT)" -Preset "$(PRESET)" -Clean $(if $(VS_INSTALL),-VsInstall "$(VS_INSTALL)",)
presets:
cmake --list-presets
help:
@echo audio.cpp Windows build targets:
@echo make -f Makefile.windows CUDA Release build of audiocpp_cli
@echo make -f Makefile.windows cpu CPU Release build of audiocpp_cli
@echo make -f Makefile.windows cuda CUDA Release build of audiocpp_cli
@echo make -f Makefile.windows cuda-debug CUDA Debug build with /O2 /Zi
@echo make -f Makefile.windows vulkan Vulkan Release build of audiocpp_cli
@echo make -f Makefile.windows vulkan-debug Vulkan Debug build with /O2 /Zi
@echo make -f Makefile.windows build TARGET=name Build one target from the selected Windows preset
@echo make -f Makefile.windows presets List available CMake presets
@echo $(BUILD_CMD) Same build from cmd.exe without make
@echo.
@echo Examples:
@echo make -f Makefile.windows cpu JOBS=8
@echo make -f Makefile.windows cuda JOBS=8
@echo make -f Makefile.windows vulkan JOBS=8
@echo make -f Makefile.windows cuda NATIVE_CPU=OFF JOBS=8
@echo make -f Makefile.windows cuda-debug TARGET=audiocpp_cli JOBS=8
@echo make -f Makefile.windows cuda VS_INSTALL="C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools"

View file

@ -0,0 +1,436 @@
{
"nbformat": 4,
"nbformat_minor": 0,
"metadata": {
"colab": {
"provenance": [],
"gpuType": "T4",
"include_colab_link": true
},
"kernelspec": {
"name": "python3",
"display_name": "Python 3"
},
"language_info": {
"name": "python"
},
"accelerator": "GPU"
},
"cells": [
{
"cell_type": "markdown",
"metadata": {
"id": "view-in-github",
"colab_type": "text"
},
"source": [
"<a href=\"https://colab.research.google.com/github/0xShug0/audio.cpp/blob/main/Notebooks/colab_audio_cpp.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>\n",
"\n",
"# audio.cpp WebUI\n",
"Run the cell below to build audio.cpp with CUDA and open its WebUI. The public link remains active while the cell is running. Anyone with the link can use the WebUI, so do not share it."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"id": "start-audio-cpp",
"cellView": "form",
"collapsed": true
},
"outputs": [],
"source": [
"# @title Start audio.cpp\n",
"\n",
"import json\n",
"import os\n",
"import platform\n",
"import re\n",
"import hashlib\n",
"import shutil\n",
"import socket\n",
"import subprocess\n",
"import sys\n",
"import tarfile\n",
"import time\n",
"import urllib.request\n",
"from pathlib import Path\n",
"\n",
"from IPython.display import HTML, clear_output, display\n",
"\n",
"REPO_DIR = Path(\"/content/audio.cpp\")\n",
"REPO_URL = \"https://github.com/0xShug0/audio.cpp.git\"\n",
"REPO_BRANCH = \"main\"\n",
"BUILD_DIR = REPO_DIR / \"build/colab-cuda\"\n",
"with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as port_socket:\n",
" port_socket.bind((\"127.0.0.1\", 0))\n",
" SERVER_PORT = port_socket.getsockname()[1]\n",
"BUILD_LOG = Path(\"/content/audio_cpp_build.log\")\n",
"SERVER_LOG = Path(\"/content/audio_cpp_server.log\")\n",
"TUNNEL_LOG = Path(\"/content/audio_cpp_tunnel.log\")\n",
"CLOUDFLARED = Path(\"/content/cloudflared\")\n",
"RELEASE_API_URL = \"https://api.github.com/repos/0xShug0/audio.cpp/releases/latest\"\n",
"PREBUILT_ASSET_SUFFIX = \"-bin-ubuntu-x64-cuda12.8-colab.tar.gz\"\n",
"PREBUILT_CUDA_ARCHS = {\"75\"}\n",
"PREBUILT_ROOT = Path(\"/content/audio-cpp-prebuilt\")\n",
"\n",
"# The prebuilt is linked against CUDA 12. If the Colab image doesn't provide\n",
"# those libraries (e.g. it ships CUDA 13), they are fetched from the official\n",
"# NVIDIA pip wheels into this isolated folder (the Colab Python env is untouched).\n",
"CUDA12_LIBS_DIR = Path(\"/content/cuda12-libs\")\n",
"CUDA12_LIB_PACKAGES = {\n",
" \"libcublas\": \"nvidia-cublas-cu12\", # also covers libcublasLt\n",
" \"libcudart\": \"nvidia-cuda-runtime-cu12\",\n",
" \"libcufft\": \"nvidia-cufft-cu12\",\n",
" \"libcurand\": \"nvidia-curand-cu12\",\n",
" \"libnvrtc\": \"nvidia-cuda-nvrtc-cu12\",\n",
"}\n",
"\n",
"\n",
"def latest_colab_prebuilt():\n",
" request = urllib.request.Request(\n",
" RELEASE_API_URL,\n",
" headers={\"Accept\": \"application/vnd.github+json\", \"User-Agent\": \"audio.cpp-colab\"},\n",
" )\n",
" with urllib.request.urlopen(request, timeout=30) as response:\n",
" release = json.load(response)\n",
" matches = [asset for asset in release.get(\"assets\", []) if asset[\"name\"].endswith(PREBUILT_ASSET_SUFFIX)]\n",
" if len(matches) != 1:\n",
" raise RuntimeError(\"The latest release does not contain a Colab CUDA prebuilt.\")\n",
" asset = matches[0]\n",
" digest = asset.get(\"digest\", \"\")\n",
" if not digest.startswith(\"sha256:\"):\n",
" raise RuntimeError(\"The Colab CUDA prebuilt does not have a SHA-256 digest.\")\n",
" return asset[\"browser_download_url\"], digest.removeprefix(\"sha256:\")\n",
"\n",
"\n",
"def run(command, *, cwd=None, env=None, log=None):\n",
" if log is None:\n",
" subprocess.run(command, cwd=cwd, env=env, check=True)\n",
" return\n",
" with log.open(\"a\", encoding=\"utf-8\") as output:\n",
" try:\n",
" subprocess.run(\n",
" command, cwd=cwd, env=env, check=True, stdout=output, stderr=subprocess.STDOUT\n",
" )\n",
" except subprocess.CalledProcessError as error:\n",
" command_text = \" \".join(map(str, command))\n",
" raise RuntimeError(f\"Command failed: {command_text}\\n\\n{tail(log)}\") from error\n",
"\n",
"\n",
"def tail(path, line_count=40):\n",
" if not path.exists():\n",
" return \"\"\n",
" return \"\".join(path.read_text(encoding=\"utf-8\", errors=\"replace\").splitlines(True)[-line_count:])\n",
"\n",
"\n",
"def run_build(command, *, cwd, env, log):\n",
" progress_pattern = re.compile(r\"\\[(\\d+)/(\\d+)\\]\")\n",
" with log.open(\"a\", encoding=\"utf-8\") as output:\n",
" process = subprocess.Popen(\n",
" command, cwd=cwd, env=env, stdout=output, stderr=subprocess.STDOUT\n",
" )\n",
" last_progress = None\n",
" while process.poll() is None:\n",
" matches = progress_pattern.findall(\n",
" log.read_text(encoding=\"utf-8\", errors=\"replace\")\n",
" )\n",
" if matches and matches[-1] != last_progress:\n",
" completed, total = map(int, matches[-1])\n",
" width = 30\n",
" filled = min(width, completed * width // total)\n",
" bar = \"#\" * filled + \"-\" * (width - filled)\n",
" percent = completed * 100 // total\n",
" clear_output(wait=True)\n",
" print(f\"Building audio.cpp with CUDA [{bar}] {percent}% ({completed}/{total})\")\n",
" last_progress = matches[-1]\n",
" time.sleep(1)\n",
" return_code = process.wait()\n",
" if return_code != 0:\n",
" command_text = \" \".join(map(str, command))\n",
" raise RuntimeError(f\"Command failed: {command_text}\\n\\n{tail(log)}\")\n",
"\n",
"\n",
"def stop(process):\n",
" if process is None or process.poll() is not None:\n",
" return\n",
" process.terminate()\n",
" try:\n",
" process.wait(timeout=10)\n",
" except subprocess.TimeoutExpired:\n",
" process.kill()\n",
" process.wait()\n",
"\n",
"\n",
"def missing_shared_libs(binary, env):\n",
" \"\"\"Return the shared libraries `ldd` cannot resolve for this binary.\"\"\"\n",
" result = subprocess.run(\n",
" [\"ldd\", str(binary)], env=env, capture_output=True, text=True\n",
" )\n",
" return sorted(\n",
" {\n",
" line.split(\"=>\")[0].strip()\n",
" for line in result.stdout.splitlines()\n",
" if \"not found\" in line\n",
" }\n",
" )\n",
"\n",
"\n",
"def ensure_runtime_libs(binary):\n",
" \"\"\"Make sure every shared library the binary needs can be resolved.\n",
"\n",
" Returns an environment dict (with LD_LIBRARY_PATH extended if needed) that\n",
" must be used when launching the binary.\n",
" \"\"\"\n",
" env = os.environ.copy()\n",
" missing = missing_shared_libs(binary, env)\n",
" if not missing:\n",
" return env\n",
"\n",
" print(f\"Missing CUDA libraries: {', '.join(missing)}\", flush=True)\n",
"\n",
" packages = set()\n",
" unknown = []\n",
" for lib in missing:\n",
" stem = lib.split(\".so\")[0]\n",
" package = next(\n",
" (pkg for prefix, pkg in CUDA12_LIB_PACKAGES.items() if stem.startswith(prefix)),\n",
" None,\n",
" )\n",
" if package is None:\n",
" unknown.append(lib)\n",
" else:\n",
" packages.add(package)\n",
" if unknown:\n",
" raise RuntimeError(f\"Cannot provide these libraries automatically: {', '.join(unknown)}\")\n",
"\n",
" print(f\"Installing CUDA 12 runtime libraries: {', '.join(sorted(packages))}...\", flush=True)\n",
" run(\n",
" [\n",
" sys.executable, \"-m\", \"pip\", \"install\", \"--quiet\", \"--no-deps\",\n",
" \"--target\", str(CUDA12_LIBS_DIR), *sorted(packages),\n",
" ],\n",
" log=BUILD_LOG,\n",
" )\n",
"\n",
" lib_dirs = sorted({str(path.parent) for path in CUDA12_LIBS_DIR.rglob(\"lib*.so*\")})\n",
" existing = env.get(\"LD_LIBRARY_PATH\", \"\")\n",
" env[\"LD_LIBRARY_PATH\"] = \":\".join(lib_dirs + ([existing] if existing else []))\n",
"\n",
" still_missing = missing_shared_libs(binary, env)\n",
" if still_missing:\n",
" raise RuntimeError(f\"Still missing after install: {', '.join(still_missing)}\")\n",
" return env\n",
"\n",
"\n",
"if subprocess.run(\n",
" [\"nvidia-smi\"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL\n",
").returncode != 0:\n",
" raise RuntimeError(\n",
" \"No NVIDIA GPU was detected. In Colab, select Runtime > Change runtime type > T4 GPU, \"\n",
" \"restart the session, and run this cell again.\"\n",
" )\n",
"\n",
"if platform.machine() != \"x86_64\":\n",
" raise RuntimeError(f\"This notebook currently supports x86_64 Colab runtimes, not {platform.machine()}.\")\n",
"\n",
"BUILD_LOG.write_text(\"\", encoding=\"utf-8\")\n",
"SERVER_LOG.write_text(\"\", encoding=\"utf-8\")\n",
"TUNNEL_LOG.write_text(\"\", encoding=\"utf-8\")\n",
"\n",
"print(\"Preparing the Colab runtime...\", flush=True)\n",
"run([\"apt-get\", \"update\", \"-qq\"], log=BUILD_LOG)\n",
"run(\n",
" [\n",
" \"apt-get\", \"install\", \"-y\", \"-qq\",\n",
" \"ca-certificates\", \"curl\", \"ffmpeg\", \"libgomp1\", \"libssl3\",\n",
" ],\n",
" log=BUILD_LOG,\n",
")\n",
"\n",
"compute_capability = subprocess.check_output(\n",
" [\"nvidia-smi\", \"--query-gpu=compute_cap\", \"--format=csv,noheader\"], text=True\n",
").splitlines()[0].strip()\n",
"cuda_arch = compute_capability.replace(\".\", \"\")\n",
"if not cuda_arch.isdigit():\n",
" raise RuntimeError(f\"Could not determine the CUDA architecture from: {compute_capability!r}\")\n",
"\n",
"server_binary = None\n",
"server_env = os.environ.copy()\n",
"if cuda_arch in PREBUILT_CUDA_ARCHS:\n",
" archive_path = None\n",
" try:\n",
" prebuilt_url, prebuilt_sha256 = latest_colab_prebuilt()\n",
" archive_path = Path(\"/content\") / prebuilt_url.rsplit(\"/\", 1)[-1]\n",
" print(\"Downloading the CUDA prebuilt...\", flush=True)\n",
" run([\"curl\", \"-L\", \"--fail\", \"--silent\", \"--show-error\", prebuilt_url, \"-o\", str(archive_path)], log=BUILD_LOG)\n",
" actual_sha256 = hashlib.sha256(archive_path.read_bytes()).hexdigest()\n",
" if actual_sha256 != prebuilt_sha256:\n",
" raise RuntimeError(\"The downloaded CUDA prebuilt failed SHA-256 verification.\")\n",
" if PREBUILT_ROOT.exists():\n",
" shutil.rmtree(PREBUILT_ROOT)\n",
" PREBUILT_ROOT.mkdir(parents=True)\n",
" with tarfile.open(archive_path, \"r:gz\") as archive:\n",
" destination = PREBUILT_ROOT.resolve()\n",
" for member in archive.getmembers():\n",
" member_path = (PREBUILT_ROOT / member.name).resolve()\n",
" if destination not in member_path.parents and member_path != destination:\n",
" raise RuntimeError(\"The CUDA prebuilt contains an unsafe path.\")\n",
" archive.extractall(PREBUILT_ROOT, filter=\"data\")\n",
" matches = list(PREBUILT_ROOT.rglob(\"audiocpp_server\"))\n",
" if len(matches) != 1:\n",
" raise RuntimeError(\"The CUDA prebuilt does not contain one audiocpp_server binary.\")\n",
" server_binary = matches[0]\n",
" server_binary.chmod(0o755)\n",
"\n",
" # NEW: resolve missing CUDA 12 libs (libcublas.so.12, etc.) before launch.\n",
" # If this fails, the except below falls back to building from source.\n",
" server_env = ensure_runtime_libs(server_binary)\n",
" except Exception as error:\n",
" print(f\"The CUDA prebuilt could not be used; building from source instead. ({error})\", flush=True)\n",
" server_binary = None\n",
" server_env = os.environ.copy()\n",
" finally:\n",
" if archive_path is not None:\n",
" archive_path.unlink(missing_ok=True)\n",
"\n",
"if server_binary is None:\n",
" run(\n",
" [\n",
" \"apt-get\", \"install\", \"-y\", \"-qq\", \"build-essential\", \"cmake\",\n",
" \"libssl-dev\", \"ninja-build\", \"software-properties-common\",\n",
" ],\n",
" log=BUILD_LOG,\n",
" )\n",
" if subprocess.run([\"bash\", \"-lc\", \"command -v gcc-13 && command -v g++-13\"], stdout=subprocess.DEVNULL).returncode != 0:\n",
" run([\"add-apt-repository\", \"-y\", \"ppa:ubuntu-toolchain-r/test\"], log=BUILD_LOG)\n",
" run([\"apt-get\", \"update\", \"-qq\"], log=BUILD_LOG)\n",
" run([\"apt-get\", \"install\", \"-y\", \"-qq\", \"gcc-13\", \"g++-13\"], log=BUILD_LOG)\n",
"\n",
" if not (REPO_DIR / \".git\").exists():\n",
" if REPO_DIR.exists():\n",
" raise RuntimeError(f\"{REPO_DIR} exists but is not a Git checkout. Restart the runtime and try again.\")\n",
" print(\"Downloading audio.cpp source...\", flush=True)\n",
" run([\"git\", \"clone\", \"--depth=1\", \"--branch\", REPO_BRANCH, REPO_URL, str(REPO_DIR)], log=BUILD_LOG)\n",
" else:\n",
" print(\"Using the existing audio.cpp checkout.\", flush=True)\n",
"\n",
" build_env = os.environ.copy()\n",
" build_env.update({\"CC\": \"gcc-13\", \"CXX\": \"g++-13\", \"CUDAHOSTCXX\": \"g++-13\"})\n",
" jobs = max(1, min(os.cpu_count() or 2, 4))\n",
" server_binary = BUILD_DIR / \"bin/audiocpp_server\"\n",
" print(\"Building audio.cpp with CUDA. This can take several minutes...\", flush=True)\n",
" run_build(\n",
" [\n",
" \"bash\", \"scripts/build_linux.sh\",\n",
" \"--backend\", \"cuda\",\n",
" \"--build-dir\", str(BUILD_DIR),\n",
" \"--build-type\", \"Release\",\n",
" \"--cuda-arch\", cuda_arch,\n",
" \"--deployment-build\",\n",
" \"--native-model-manager\",\n",
" \"--system-openssl\",\n",
" \"--target\", \"audiocpp_server\",\n",
" \"--jobs\", str(jobs),\n",
" ],\n",
" cwd=REPO_DIR,\n",
" env=build_env,\n",
" log=BUILD_LOG,\n",
" )\n",
" server_env = os.environ.copy()\n",
"\n",
"if not CLOUDFLARED.exists():\n",
" print(\"Installing the public tunnel...\", flush=True)\n",
" run(\n",
" [\n",
" \"curl\", \"-L\", \"--fail\", \"--silent\", \"--show-error\",\n",
" \"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64\",\n",
" \"-o\", str(CLOUDFLARED),\n",
" ],\n",
" log=BUILD_LOG,\n",
" )\n",
" CLOUDFLARED.chmod(0o755)\n",
"\n",
"server_process = None\n",
"tunnel_process = None\n",
"server_output = None\n",
"tunnel_output = None\n",
"\n",
"try:\n",
" print(\"Starting the audio.cpp WebUI...\", flush=True)\n",
" server_output = SERVER_LOG.open(\"w\", encoding=\"utf-8\")\n",
" server_process = subprocess.Popen(\n",
" [\n",
" str(server_binary), \"--ui\", \"--ui-management\",\n",
" \"--backend\", \"cuda\", \"--host\", \"127.0.0.1\",\n",
" \"--port\", str(SERVER_PORT),\n",
" ],\n",
" cwd=server_binary.parent,\n",
" env=server_env, # NEW: carries LD_LIBRARY_PATH for the CUDA 12 libs\n",
" stdout=server_output,\n",
" stderr=subprocess.STDOUT,\n",
" text=True,\n",
" )\n",
"\n",
" health_url = f\"http://127.0.0.1:{SERVER_PORT}/health\"\n",
" deadline = time.monotonic() + 120\n",
" while time.monotonic() < deadline:\n",
" if server_process.poll() is not None:\n",
" raise RuntimeError(\"audio.cpp stopped during startup.\\n\\n\" + tail(SERVER_LOG))\n",
" try:\n",
" with urllib.request.urlopen(health_url, timeout=2) as response:\n",
" if response.status == 200:\n",
" break\n",
" except Exception:\n",
" time.sleep(1)\n",
" else:\n",
" raise RuntimeError(\"audio.cpp did not become ready.\\n\\n\" + tail(SERVER_LOG))\n",
"\n",
" tunnel_output = TUNNEL_LOG.open(\"w\", encoding=\"utf-8\")\n",
" tunnel_process = subprocess.Popen(\n",
" [str(CLOUDFLARED), \"tunnel\", \"--url\", f\"http://127.0.0.1:{SERVER_PORT}\", \"--no-autoupdate\"],\n",
" stdout=tunnel_output,\n",
" stderr=subprocess.STDOUT,\n",
" text=True,\n",
" )\n",
"\n",
" public_url = None\n",
" deadline = time.monotonic() + 90\n",
" while time.monotonic() < deadline:\n",
" if tunnel_process.poll() is not None:\n",
" raise RuntimeError(\"The public tunnel stopped during startup.\\n\\n\" + tail(TUNNEL_LOG))\n",
" match = re.search(r\"https://[a-z0-9-]+\\.trycloudflare\\.com\", TUNNEL_LOG.read_text(encoding=\"utf-8\", errors=\"replace\"))\n",
" if match:\n",
" public_url = match.group(0)\n",
" break\n",
" time.sleep(1)\n",
" if public_url is None:\n",
" raise RuntimeError(\"The public tunnel did not provide a URL.\\n\\n\" + tail(TUNNEL_LOG))\n",
"\n",
" clear_output(wait=True)\n",
" display(HTML(\n",
" f'<h2>audio.cpp is running</h2>'\n",
" f'<p><a href=\"{public_url}\" target=\"_blank\" rel=\"noopener noreferrer\">Open the audio.cpp WebUI</a></p>'\n",
" '<p>Keep this cell running while you use the WebUI. Anyone with the link can access this session.</p>'\n",
" ))\n",
"\n",
" while True:\n",
" if server_process.poll() is not None:\n",
" raise RuntimeError(\"audio.cpp stopped unexpectedly.\\n\\n\" + tail(SERVER_LOG))\n",
" if tunnel_process.poll() is not None:\n",
" raise RuntimeError(\"The public tunnel stopped unexpectedly.\\n\\n\" + tail(TUNNEL_LOG))\n",
" time.sleep(5)\n",
"except KeyboardInterrupt:\n",
" clear_output(wait=True)\n",
" print(\"audio.cpp stopped.\")\n",
"finally:\n",
" stop(tunnel_process)\n",
" stop(server_process)\n",
" if tunnel_output is not None:\n",
" tunnel_output.close()\n",
" if server_output is not None:\n",
" server_output.close()\n"
]
}
]
}

1042
README.md Normal file

File diff suppressed because it is too large Load diff

266
README_fabio.md Normal file
View file

@ -0,0 +1,266 @@
Certo. Ti preparo una guida riutilizzabile per generare più modelli RKNN di CosyVoice3 Flow Estimator, uno per ogni sequenza (per esempio 200, 256, 500 e, se sostenibile, altre).
L'idea è mantenere un modello separato per ogni lunghezza, senza modificare quelli già funzionanti. Alla fine avremo una procedura per convertire, verificare e trasferire i file `.rknn` sull'Orange Pi, oltre a una tabella per scegliere quale sequenza usare durante l'inferenza.
La conversione va fatta sulla VM Debian 12 x86_64, non sull'Orange Pi: la VM usa Python 3.11 e RKNN-Toolkit2; l'Orange Pi serve per caricare e provare i `.rknn` con `librknnrt`. È anche il flusso previsto da Rockchip: conversione sul computer host e inferenza sul dispositivo.&#x20;
[image](https://www.google.com/s2/favicons?domain=https://github.com\&sz=32)
GitHub+1
Nel nostro caso useremo sempre la stessa base:
- ONNX FP32: `/mnt/rknn-work/cosyvoice3-official/flow.decoder.estimator.fp32.onnx`
- Script: `tools/rknn/prepare_cosyvoice3_rknn_flow.py`
- Target: `rk3588`
- Precisione: FP16, senza quantizzazione INT8
- Ottimizzazione: livello `3`
# Guida: conversione di CosyVoice3 Flow Estimator in RKNN per sequenze diverse
## 1. Ambiente di conversione
La conversione si esegue sulla VM Debian 12 x86_64, non sull'Orange Pi.
| Elemento | Percorso / versione |
| ------------------- | --------------------------------------------------------------------- |
| Repository | `/home/fabio/audio-cpp-rknn-upstream` |
| Ambiente Python | `rknn-env` |
| Python | 3.11 |
| Script | `tools/rknn/prepare_cosyvoice3_rknn_flow.py` |
| Modello ONNX FP32 | `/mnt/rknn-work/cosyvoice3-official/flow.decoder.estimator.fp32.onnx` |
| SoC di destinazione | `rk3588` |
| Precisione | FP16 |
| Quantizzazione INT8 | Disattivata |
| Optimization level | `3` |
Non occorre reinstallare RKNN-Toolkit2 se l'ambiente virtuale esistente funziona.
## 2. Preparare il terminale
Sulla VM:
```
cd /home/fabio/audio-cpp-rknn-upstream
source rknn-env/bin/activate
```
Controllare che Python e il Toolkit siano quelli dell'ambiente:
```
python3 -c "import sys; from rknn.api import RKNN; print(sys.version); print('RKNN Toolkit import OK')"
```
Verificare che il modello ONNX esista:
```
ls -lh /mnt/rknn-work/cosyvoice3-official/flow.decoder.estimator.fp32.onnx
```
## 3. Generare più sequenze in una sola conversione
Lo script accetta più parametri `--seq-len` nella stessa esecuzione.
Esempio: generare le sequenze 200, 256 e 500 in una nuova directory, senza sovrascrivere i modelli già creati:
```
python3 tools/rknn/prepare_cosyvoice3_rknn_flow.py \
--estimator-onnx /mnt/rknn-work/cosyvoice3-official/flow.decoder.estimator.fp32.onnx \
--seq-len 200 \
--seq-len 256 \
--seq-len 500 \
--target rk3588 \
--opt-level 3 \
--output-dir /mnt/rknn-work/cosyvoice3-rknn-fp16-multi
```
Il risultato atteso è una serie di file:
```
/mnt/rknn-work/cosyvoice3-rknn-fp16-multi/
├── flow_estimator_seq200.rknn
├── flow_estimator_seq256.rknn
└── flow_estimator_seq500.rknn
```
Ogni file è una variante del medesimo modello ONNX, preparata per una lunghezza di sequenza fissa.
### Generare una sola sequenza
Per creare soltanto la sequenza 500, il comando è:
```
python3 tools/rknn/prepare_cosyvoice3_rknn_flow.py \
--estimator-onnx /mnt/rknn-work/cosyvoice3-official/flow.decoder.estimator.fp32.onnx \
--seq-len 500 \
--target rk3588 \
--opt-level 3 \
--output-dir /mnt/rknn-work/cosyvoice3-rknn-fp16-500
```
Per creare, ad esempio, una sequenza 400, basta cambiare `--seq-len 500` in `--seq-len 400` e scegliere una nuova directory di output.
**Importante:** ogni lunghezza deve corrispondere a un modello distinto. Non si deve rinominare un modello seq500 facendolo passare per seq400.
## 4. Significato delle opzioni
| Opzione | Funzione |
| ------------------ | ----------------------------------------------------------------------------------------------- |
| `--estimator-onnx` | Indica il modello ONNX sorgente |
| `--seq-len` | Lunghezza della sequenza da convertire; può essere ripetuta |
| `--target rk3588` | Genera un modello destinato all'RK3588 |
| `--opt-level 3` | Usa il livello di ottimizzazione 3 |
| `--output-dir` | Directory in cui salvare i risultati |
| `--check-frames` | Controlla le lunghezze dei frame indicate |
| `--skip-rknn` | Esegue i controlli senza generare il file RKNN |
| `--verify-sim` | Esegue la verifica di simulazione prevista dallo script; verifica il primo `--seq-len` indicato |
Per verificare ogni sequenza con `--verify-sim`, è preferibile eseguire una conversione per volta, indicando una sola lunghezza per comando.
`--skip-rknn` è utile per controllare la preparazione senza ripetere una conversione completa.
## 5. Verificare i file generati
Dopo la conversione:
```
ls -lh /mnt/rknn-work/cosyvoice3-rknn-fp16-multi/
```
Calcolare gli hash:
```
sha256sum /mnt/rknn-work/cosyvoice3-rknn-fp16-multi/*.rknn
```
Conservare gli hash: permettono di verificare che il file trasferito sull'Orange Pi sia identico all'originale.
### Controllo già superato per seq500
Il modello già prodotto è:
```
/mnt/rknn-work/cosyvoice3-rknn-fp16-500/flow_estimator_seq500.rknn
```
Dimensione esatta:
```
717288588 byte
```
SHA256:
```
27ffb3e5408bc07993913297cf265bb1d36d9632123514c4c6057fdcbdd3d7ee
```
## 6. Verificare il caricamento sull'Orange Pi
Il caricamento va verificato con il runtime RKNN presente sulla scheda.
Il programma di test già compilato è:
```
/tmp/rknn_load_test
```
Esempio:
```
/tmp/rknn_load_test /home/nvme/cosyvoice3-rknn-fp16-500/flow_estimator_seq500.rknn
```
Un caricamento valido deve restituire:
```
rknn_init() = 0
API: 2.3.2
Driver: 0.9.8
Input: 6
Output: 1
rknn_destroy() = 0
```
Questo controllo conferma che il runtime riesce a caricare e rilasciare il modello. Non equivale ancora a una prova di inferenza.
## 7. Eseguire una prova reale con CosyVoice3
Per provare un modello specifico, si indica il suo percorso nell'`exec_plan`.
Esempio con seq500:
```
/tmp/audio-cpp-completo/build-cosyvoice3-rknn/bin/audiocpp_cli \
--task clon \
--family cosyvoice3 \
--model /home/nvme/audio-cpp-models/CosyVoice3-GGUF/cosyvoice3-q8_0.gguf \
--backend cpu \
--threads 4 \
--log \
--metrics \
--session-option 'cosyvoice3.exec_plan={"flow":{"impl":"rknn","artifacts":{"main":"/home/nvme/cosyvoice3-rknn-fp16-500/flow_estimator_seq500.rknn"}}}' \
--text "Buongiorno a tutti. Oggi vorrei raccontarvi una storia interessante. È una giornata speciale, piena di novità e di occasioni per imparare qualcosa di nuovo. Spero che possiate trascorrere momenti sereni, con entusiasmo e tanta voglia di fare. Vi auguro una splendida giornata e vi ringrazio per avermi ascoltato." \
--voice-ref /home/nvme/CosyVoice/mia_voce_2_3_9s.wav \
--reference-text "zia Ermia detta" \
--out /home/nvme/audio-cpp-models/test-cosyvoice3-seq500.wav
```
Per provare seq256 o seq200, si cambia soltanto il percorso del file `.rknn` e il nome del WAV di uscita.
`--backend cpu` riguarda il backend del modello GGUF: non disabilita automaticamente RKNN per il Flow Estimator. L'implementazione del Flow è selezionata separatamente dall'`exec_plan`.
## 8. Come scegliere le sequenze
La lunghezza della sequenza deve essere sufficiente per i frame richiesti dall'inferenza.
| Modello | Capacità nominale | Impiego |
| ------- | ----------------- | ----------------- |
| seq200 | 200 frame | Test brevi |
| seq256 | 256 frame | Test brevi e medi |
| seq500 | 500 frame | Test più lunghi |
Il programma può scegliere il modello più piccolo che contiene il numero di frame richiesto, se gli vengono forniti più modelli compatibili. Se la sequenza supera la capacità massima disponibile, il codice attuale può ricorrere al percorso CPU.
### Attenzione alla memoria NPU
Sul nostro Orange Pi la memoria riservata dal driver è limitata. Il modello seq500 ha dimensioni su disco di circa 684 MiB, ma la sua occupazione durante il caricamento NPU è molto maggiore.
Nei test già eseguiti, seq500 ha richiesto circa 3,7 GiB di memoria DMA. Per questo motivo non bisogna presumere che seq200, seq256 e seq500 possano essere caricati contemporaneamente.
Per i test iniziali, caricare **un solo modello per volta**. In particolare, per provare seq500 non aggiungere contemporaneamente i modelli seq200 e seq256.
## 9. Tabella dei risultati da compilare
Dopo ogni inferenza, annotare i dati dei log:
| Modello | Frame richiesti | Tempo Flow | Tempo totale | Durata audio | RTF | Memoria massima | Esito |
| ------- | --------------- | ---------- | ------------ | ------------ | ---- | --------------- | ----- |
| seq200 | <br> | <br> | <br> | <br> | <br> | <br> | <br> |
| seq256 | <br> | <br> | <br> | <br> | <br> | <br> | <br> |
| seq500 | <br> | <br> | <br> | <br> | <br> | <br> | <br> |
Per confrontare correttamente i modelli, usare la stessa frase, la stessa voce di riferimento, lo stesso testo di riferimento e le stesse impostazioni.
## 10. Procedura consigliata per ogni nuova sequenza
1. Partire sempre dal medesimo ONNX FP32.
2. Scegliere una nuova lunghezza con `--seq-len`.
3. Usare `--target rk3588` e `--opt-level 3`.
4. Generare il modello FP16 senza quantizzazione INT8.
5. Salvare il risultato in una directory dedicata, senza sovrascrivere modelli già verificati.
6. Controllare dimensione e SHA256.
7. Trasferire il modello sull'Orange Pi.
8. Verificare `rknn_init()` con il loader C++.
9. Eseguire una generazione audio reale.
10. Annotare tempi, RTF, memoria e qualità audio.
**Regola fondamentale:** un `.rknn` che si carica correttamente non è ancora necessariamente un modello valido per l'inferenza. La verifica definitiva è una generazione audio reale con il Flow Estimator che utilizza effettivamente quel modello.
Nota: seq200, seq256 e seq500 sono già stati generati in precedenza; seq500 è anche stato caricato correttamente sull'Orange Pi con RKNN API 2.3.2 e driver 0.9.8. La guida usa una nuova cartella `fp16-multi` negli esempi per non sovrascrivere quei risultati.
## 11. Repository di riferimento
- Progetto originale audio.cpp: https://github.com/0xShug0/audio.cpp

237
app/cli/args.cpp Normal file
View file

@ -0,0 +1,237 @@
#include "args.h"
#include <charconv>
#include <iostream>
#include <set>
#include <stdexcept>
namespace minitts::cli {
namespace {
// Every option name the CLI looked for, split by whether the lookup consumes the following
// argument. Populated by the lookups below, so a new flag is recognised the moment its lookup
// is added and there is no second list to keep in sync.
std::set<std::string> & value_names() {
static std::set<std::string> names;
return names;
}
std::set<std::string> & flag_names() {
static std::set<std::string> names;
return names;
}
void record_value_query(const std::string & name) {
value_names().insert(name);
}
void record_flag_query(const std::string & name) {
flag_names().insert(name);
}
std::vector<std::string> args_nobody_asked_for(int argc, char ** argv) {
const auto & values = value_names();
const auto & flags = flag_names();
std::vector<std::string> unknown;
for (int i = 1; i < argc; ++i) {
const std::string token = argv[i];
if (token.rfind("--", 0) != 0 || token == "--") {
continue;
}
// Only an option that takes a value consumes the next argument, so only then can the
// next argument be something that merely looks like an option.
if (values.count(token) != 0) {
++i;
continue;
}
if (flags.count(token) != 0) {
continue;
}
unknown.push_back(token);
}
return unknown;
}
// Set once the strict check has spoken for this run, so the warning below does not repeat it.
bool strict_check_ran = false;
} // namespace
void require_known_args(int argc, char ** argv) {
strict_check_ran = true;
const auto unknown = args_nobody_asked_for(argc, argv);
if (unknown.empty()) {
return;
}
std::string message = unknown.size() > 1 ? "unknown options:" : "unknown option:";
for (const auto & token : unknown) {
message += " " + token;
}
throw std::runtime_error(message);
}
void warn_ignored_args(int argc, char ** argv) {
if (strict_check_ran) {
return;
}
const auto ignored = args_nobody_asked_for(argc, argv);
if (ignored.empty()) {
return;
}
std::cerr << "audiocpp_cli warning: ignored option";
if (ignored.size() > 1) {
std::cerr << "s";
}
std::cerr << ":";
for (const auto & token : ignored) {
std::cerr << " " << token;
}
std::cerr << "\n";
}
std::optional<std::string> find_arg(int argc, char ** argv, const std::string & name) {
record_value_query(name);
for (int i = 1; i + 1 < argc; ++i) {
if (argv[i] == name) {
return std::string(argv[i + 1]);
}
}
return std::nullopt;
}
bool has_arg(int argc, char ** argv, const std::string & name) {
record_flag_query(name);
for (int i = 1; i < argc; ++i) {
if (argv[i] == name) {
return true;
}
}
return false;
}
std::vector<std::string> collect_args(int argc, char ** argv, const std::string & name) {
record_value_query(name);
std::vector<std::string> values;
for (int i = 1; i + 1 < argc; ++i) {
if (argv[i] == name) {
values.emplace_back(argv[i + 1]);
}
}
return values;
}
std::unordered_map<std::string, std::string> collect_key_value_args(
int argc,
char ** argv,
const std::string & name) {
std::unordered_map<std::string, std::string> values;
for (const auto & item : collect_args(argc, argv, name)) {
const auto pos = item.find('=');
if (pos == std::string::npos || pos == 0 || pos + 1 >= item.size()) {
throw std::runtime_error("expected " + name + " key=value, got: " + item);
}
values[item.substr(0, pos)] = item.substr(pos + 1);
}
return values;
}
void set_option(
std::unordered_map<std::string, std::string> & options,
const std::string & key,
const std::string & value) {
const auto [it, inserted] = options.emplace(key, value);
if (!inserted && it->second != value) {
throw std::runtime_error("conflicting request option value for " + key);
}
}
void set_option_from_arg(
int argc,
char ** argv,
const std::string & arg_name,
const std::string & option_key,
std::unordered_map<std::string, std::string> & options) {
if (const auto value = find_arg(argc, argv, arg_name)) {
set_option(options, option_key, *value);
}
}
int parse_int_arg(int argc, char ** argv, const std::string & name, int fallback) {
if (const auto value = find_arg(argc, argv, name)) {
return std::stoi(*value);
}
return fallback;
}
int parse_non_negative_int(const std::string & value, const std::string & name) {
int parsed = -1;
const auto * last = value.data() + value.size();
const auto [ptr, ec] = std::from_chars(value.data(), last, parsed);
if (ec != std::errc{} || ptr != last || parsed < 0) {
throw std::runtime_error(name + " expects a non-negative integer, got: " + value);
}
return parsed;
}
std::optional<float> parse_optional_float_arg(int argc, char ** argv, const std::string & name) {
if (const auto value = find_arg(argc, argv, name)) {
return std::stof(*value);
}
return std::nullopt;
}
std::optional<std::filesystem::path> optional_path_arg(int argc, char ** argv, const std::string & name) {
if (const auto value = find_arg(argc, argv, name)) {
return std::filesystem::path(*value);
}
return std::nullopt;
}
engine::core::BackendType parse_backend(const std::string & value) {
if (value == "cpu") {
return engine::core::BackendType::Cpu;
}
if (value == "cuda") {
return engine::core::BackendType::Cuda;
}
if (value == "hip" || value == "rocm") {
return engine::core::BackendType::Hip;
}
if (value == "vulkan") {
return engine::core::BackendType::Vulkan;
}
if (value == "metal") {
return engine::core::BackendType::Metal;
}
if (value == "webgpu") {
return engine::core::BackendType::WebGPU;
}
if (value == "best") {
return engine::core::BackendType::BestAvailable;
}
throw std::runtime_error("unsupported backend: " + value);
}
engine::audio::WavSampleFormat parse_wav_sample_format(const std::string & value) {
if (value == "pcm16") {
return engine::audio::WavSampleFormat::Pcm16;
}
if (value == "pcm24") {
return engine::audio::WavSampleFormat::Pcm24;
}
if (value == "float32") {
return engine::audio::WavSampleFormat::Float32;
}
throw std::runtime_error("unsupported --out-format: " + value + " (expected pcm16, pcm24, or float32)");
}
engine::audio::WavWriteOptions wav_write_options_from_cli(int argc, char ** argv) {
engine::audio::WavWriteOptions options;
if (const auto value = find_arg(argc, argv, "--out-format")) {
options.format = parse_wav_sample_format(*value);
}
return options;
}
} // namespace minitts::cli

51
app/cli/args.h Normal file
View file

@ -0,0 +1,51 @@
#pragma once
#include "engine/framework/audio/wav_writer.h"
#include "engine/framework/core/backend.h"
#include <filesystem>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
namespace minitts::cli {
// Throws if the command line carries a --flag that no lookup ever asked for, so a typo like
// --bakend is refused instead of being silently discarded. Call once every option has been
// read, which on the run paths is before the model does any work.
void require_known_args(int argc, char ** argv);
// The same check reported as a warning, for the informational commands. Those return before the
// rest of the options are read, so all that can honestly be said there is that an option was
// ignored, not that it was misspelled. Does nothing if require_known_args already ran.
void warn_ignored_args(int argc, char ** argv);
std::optional<std::string> find_arg(int argc, char ** argv, const std::string & name);
bool has_arg(int argc, char ** argv, const std::string & name);
std::vector<std::string> collect_args(int argc, char ** argv, const std::string & name);
std::unordered_map<std::string, std::string> collect_key_value_args(
int argc,
char ** argv,
const std::string & name);
void set_option(
std::unordered_map<std::string, std::string> & options,
const std::string & key,
const std::string & value);
void set_option_from_arg(
int argc,
char ** argv,
const std::string & arg_name,
const std::string & option_key,
std::unordered_map<std::string, std::string> & options);
int parse_int_arg(int argc, char ** argv, const std::string & name, int fallback);
int parse_non_negative_int(const std::string & value, const std::string & name);
std::optional<float> parse_optional_float_arg(int argc, char ** argv, const std::string & name);
std::optional<std::filesystem::path> optional_path_arg(int argc, char ** argv, const std::string & name);
engine::core::BackendType parse_backend(const std::string & value);
engine::audio::WavSampleFormat parse_wav_sample_format(const std::string & value);
// WAV writer settings for --out and --out-dir audio. With no --out-format this is the default
// 16-bit hard-clipped output every route has always written.
engine::audio::WavWriteOptions wav_write_options_from_cli(int argc, char ** argv);
} // namespace minitts::cli

300
app/cli/batch.cpp Normal file
View file

@ -0,0 +1,300 @@
#include "batch.h"
#include "args.h"
#include "request.h"
#include "engine/framework/io/json.h"
#include <algorithm>
#include <cctype>
#include <fstream>
#include <iterator>
#include <sstream>
#include <stdexcept>
#include <utility>
namespace minitts::cli {
namespace {
bool is_wav_path(const std::filesystem::path & path) {
std::string ext = path.extension().string();
std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
return ext == ".wav";
}
std::string lowercase_extension(const std::filesystem::path & path) {
std::string ext = path.extension().string();
std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
return ext;
}
std::string read_text_file(const std::filesystem::path & path) {
std::ifstream input(path);
if (!input) {
throw std::runtime_error("failed to open batch text file: " + path.string());
}
std::ostringstream raw;
raw << input.rdbuf();
return raw.str();
}
std::string normalize_text_as_paragraph(const std::string & text) {
std::string normalized;
bool in_space = false;
for (unsigned char ch : text) {
if (std::isspace(ch)) {
in_space = !normalized.empty();
continue;
}
if (in_space) {
normalized.push_back(' ');
in_space = false;
}
normalized.push_back(static_cast<char>(ch));
}
return normalized;
}
std::string read_plain_text_batch_file(const std::filesystem::path & path) {
return normalize_text_as_paragraph(read_text_file(path));
}
std::string read_json_text_batch_file(const std::filesystem::path & path) {
const auto root = engine::io::json::parse_file(path);
if (root.is_string()) {
return normalize_text_as_paragraph(root.as_string());
}
if (root.is_object()) {
if (const auto * input = root.find("input"); input != nullptr && input->is_string()) {
return normalize_text_as_paragraph(input->as_string());
}
if (const auto * text = root.find("text"); text != nullptr && text->is_string()) {
return normalize_text_as_paragraph(text->as_string());
}
}
throw std::runtime_error("--batch-text-dir JSON file requires a string root, input, or text: " + path.string());
}
struct TextBatchFileFormat {
const char * extension;
std::string (*read)(const std::filesystem::path & path);
};
const TextBatchFileFormat kTextBatchFileFormats[] = {
{".txt", read_plain_text_batch_file},
{".md", read_plain_text_batch_file},
{".json", read_json_text_batch_file},
};
const TextBatchFileFormat * text_batch_format_for(const std::filesystem::path & path) {
const auto ext = lowercase_extension(path);
for (const auto & format : kTextBatchFileFormats) {
if (ext == format.extension) {
return &format;
}
}
return nullptr;
}
std::string supported_text_batch_extensions() {
std::ostringstream out;
for (size_t i = 0; i < std::size(kTextBatchFileFormats); ++i) {
if (i != 0) {
out << ", ";
}
out << kTextBatchFileFormats[i].extension;
}
return out.str();
}
minitts::app::AppBatchRequest build_request_sequence_from_json(
const std::filesystem::path & sequence_path) {
const auto root = engine::io::json::parse_file(sequence_path);
const auto * requests_value = root.is_array() ? &root : root.find("requests");
if (requests_value == nullptr) {
throw std::runtime_error("request sequence json requires an array or a requests array");
}
minitts::app::AppBatchRequest batch;
const auto base_dir = sequence_path.parent_path();
int index = 0;
for (const auto & item : requests_value->as_array()) {
std::ostringstream fallback;
fallback << "request_" << index;
batch.requests.push_back(minitts::app::AppRequest{
json_optional_string(item, "id").value_or(fallback.str()),
build_request_from_json(item, base_dir),
});
++index;
}
if (batch.requests.empty()) {
throw std::runtime_error("request sequence must contain at least one request");
}
return batch;
}
minitts::app::AppBatchRequest build_text_file_batch(
const std::filesystem::path & path,
const engine::runtime::TaskRequest & base_request,
const std::string & language) {
std::ifstream input(path);
if (!input) {
throw std::runtime_error("failed to open --batch-text-file: " + path.string());
}
minitts::app::AppBatchRequest batch;
std::string line;
int line_number = 0;
while (std::getline(input, line)) {
++line_number;
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
if (line.empty()) {
continue;
}
auto request = base_request;
request.text_input = engine::runtime::Transcript{line, language};
std::ostringstream id;
id << "line_" << line_number;
batch.requests.push_back(minitts::app::AppRequest{id.str(), std::move(request)});
}
if (batch.requests.empty()) {
throw std::runtime_error("--batch-text-file contains no non-empty requests");
}
return batch;
}
minitts::app::AppBatchRequest build_text_dir_batch(
const std::filesystem::path & dir,
const engine::runtime::TaskRequest & base_request,
const std::string & language) {
if (!std::filesystem::is_directory(dir)) {
throw std::runtime_error("--batch-text-dir must be an existing directory: " + dir.string());
}
std::vector<std::filesystem::path> text_paths;
for (const auto & entry : std::filesystem::directory_iterator(dir)) {
if (entry.is_regular_file() && text_batch_format_for(entry.path()) != nullptr) {
text_paths.push_back(entry.path());
}
}
std::sort(text_paths.begin(), text_paths.end());
if (text_paths.empty()) {
throw std::runtime_error(
"--batch-text-dir contains no supported text files (" + supported_text_batch_extensions() + "): " +
dir.string());
}
minitts::app::AppBatchRequest batch;
batch.requests.reserve(text_paths.size());
for (const auto & path : text_paths) {
const auto * format = text_batch_format_for(path);
const auto paragraph = format->read(path);
if (paragraph.empty()) {
throw std::runtime_error("--batch-text-dir file contains no non-whitespace text: " + path.string());
}
auto request = base_request;
request.text_input = engine::runtime::Transcript{paragraph, language};
batch.requests.push_back(minitts::app::AppRequest{path.stem().string(), std::move(request)});
}
return batch;
}
minitts::app::AppBatchRequest build_audio_dir_batch(
const std::filesystem::path & dir,
const engine::runtime::TaskRequest & base_request,
const std::string & audio_role) {
if (!std::filesystem::is_directory(dir)) {
throw std::runtime_error("--batch-audio-dir must be an existing directory: " + dir.string());
}
std::vector<std::filesystem::path> audio_paths;
for (const auto & entry : std::filesystem::directory_iterator(dir)) {
if (entry.is_regular_file() && is_wav_path(entry.path())) {
audio_paths.push_back(entry.path());
}
}
std::sort(audio_paths.begin(), audio_paths.end());
minitts::app::AppBatchRequest batch;
batch.requests.reserve(audio_paths.size());
for (const auto & path : audio_paths) {
auto request = base_request;
if (audio_role == "audio") {
request.audio_input = read_audio_buffer(path);
} else if (audio_role == "voice_ref") {
if (!request.voice.has_value()) {
request.voice = engine::runtime::VoiceCondition{};
}
if (!request.voice->speaker.has_value()) {
request.voice->speaker = engine::runtime::VoiceReference{};
}
request.voice->speaker->audio = read_audio_buffer(path);
} else if (audio_role == "source_audio" ||
audio_role == "target_voice" ||
audio_role == "prosody_ref" ||
audio_role == "style_ref") {
set_option(request.options, audio_role, path.string());
} else {
throw std::runtime_error(
"--batch-audio-role must be audio, voice_ref, source_audio, target_voice, prosody_ref, or style_ref");
}
batch.requests.push_back(minitts::app::AppRequest{path.stem().string(), std::move(request)});
}
if (batch.requests.empty()) {
throw std::runtime_error("--batch-audio-dir contains no .wav files: " + dir.string());
}
return batch;
}
} // namespace
bool has_batch_input(int argc, char ** argv) {
int count = 0;
count += optional_path_arg(argc, argv, "--request-sequence").has_value() ? 1 : 0;
count += optional_path_arg(argc, argv, "--batch-text-file").has_value() ? 1 : 0;
count += optional_path_arg(argc, argv, "--batch-text-dir").has_value() ? 1 : 0;
count += optional_path_arg(argc, argv, "--batch-audio-dir").has_value() ? 1 : 0;
return count > 0;
}
minitts::app::AppBatchRequest build_batch_request_from_cli(
int argc,
char ** argv,
const engine::runtime::TaskRequest & base_request,
const std::string & audio_role) {
const auto request_sequence_path = optional_path_arg(argc, argv, "--request-sequence");
const auto batch_text_file = optional_path_arg(argc, argv, "--batch-text-file");
const auto batch_text_dir = optional_path_arg(argc, argv, "--batch-text-dir");
const auto batch_audio_dir = optional_path_arg(argc, argv, "--batch-audio-dir");
const int count =
(request_sequence_path.has_value() ? 1 : 0) +
(batch_text_file.has_value() ? 1 : 0) +
(batch_text_dir.has_value() ? 1 : 0) +
(batch_audio_dir.has_value() ? 1 : 0);
if (count == 0) {
return minitts::app::AppBatchRequest{};
}
if (count > 1) {
throw std::runtime_error(
"choose only one of --request-sequence, --batch-text-file, --batch-text-dir, or --batch-audio-dir");
}
if (request_sequence_path.has_value()) {
return build_request_sequence_from_json(*request_sequence_path);
}
if (batch_text_file.has_value()) {
return build_text_file_batch(
*batch_text_file,
base_request,
find_arg(argc, argv, "--language").value_or(""));
}
if (batch_text_dir.has_value()) {
return build_text_dir_batch(
*batch_text_dir,
base_request,
find_arg(argc, argv, "--language").value_or(""));
}
return build_audio_dir_batch(*batch_audio_dir, base_request, audio_role);
}
} // namespace minitts::cli

19
app/cli/batch.h Normal file
View file

@ -0,0 +1,19 @@
#pragma once
#include "../workflow/execution.h"
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
namespace minitts::cli {
bool has_batch_input(int argc, char ** argv);
minitts::app::AppBatchRequest build_batch_request_from_cli(
int argc,
char ** argv,
const engine::runtime::TaskRequest & base_request,
const std::string & audio_role);
} // namespace minitts::cli

1129
app/cli/main.cpp Normal file

File diff suppressed because it is too large Load diff

46
app/cli/partial_render.h Normal file
View file

@ -0,0 +1,46 @@
#pragma once
#include <string>
namespace minitts::cli {
// Partials are the text newly decoded since the last one, so they concatenate into the transcript.
// A terminal can therefore just append them and the transcript scrolls like ordinary output, while
// redirected output keeps the labelled line-per-update form so pipes and logs stay parseable.
//
// Returns the bytes to write rather than taking a stream, so the caller decides when to flush and
// the behaviour can be tested without a terminal.
class PartialTextRenderer {
public:
explicit PartialTextRenderer(bool interactive) : interactive_(interactive) {}
std::string render(const std::string & text) {
if (!interactive_) {
return "partial_text=" + text + "\n";
}
wrote_ = wrote_ || !text.empty();
return text;
}
// Snapshots may revise earlier provisional words. Give each a separate
// labelled line; never append them into the committed transcript.
std::string render_snapshot(const std::string & text) {
return finish() + "partial_text_snapshot=" + text + "\n";
}
// Terminates the transcript line so following output starts on a fresh row. Empty when no
// line was left open, so a run that produced no partials does not print a stray blank line.
std::string finish() {
if (!interactive_ || !wrote_) {
return {};
}
wrote_ = false;
return "\n";
}
private:
bool interactive_ = false;
bool wrote_ = false;
};
} // namespace minitts::cli

607
app/cli/request.cpp Normal file
View file

@ -0,0 +1,607 @@
#include "request.h"
#include "args.h"
#include "../common/base64.h"
#include "engine/framework/audio/wav_reader.h"
#include <cmath>
#include <cstddef>
#include <filesystem>
#include <fstream>
#include <initializer_list>
#include <iomanip>
#include <iterator>
#include <sstream>
#include <string>
#include <system_error>
#include <utility>
namespace minitts::cli {
using engine::runtime::InvalidRequestError;
namespace {
std::string path_arg_string(const std::filesystem::path & path) {
return path.string();
}
std::filesystem::path resolve_case_path(
const std::filesystem::path & base_dir,
const std::string & value) {
std::filesystem::path path(value);
return path.is_absolute() ? path : base_dir / path;
}
void set_option_from_json_field(
std::unordered_map<std::string, std::string> & options,
const engine::io::json::Value & object,
const std::string & field,
const std::string & option_key) {
const auto * value = object.find(field);
if (value != nullptr && !value->is_null()) {
set_option(options, option_key, json_option_string(*value));
}
}
struct ArtifactKindName {
const char * name;
engine::runtime::ArtifactKind kind;
};
// The names the server writes a result artifact's kind with.
constexpr ArtifactKindName kArtifactKinds[] = {
{"speaker_embedding", engine::runtime::ArtifactKind::SpeakerEmbedding},
{"style_embedding", engine::runtime::ArtifactKind::StyleEmbedding},
{"prompt_embedding", engine::runtime::ArtifactKind::PromptEmbedding},
{"acoustic_tokens", engine::runtime::ArtifactKind::AcousticTokens},
{"midi", engine::runtime::ArtifactKind::Midi},
{"transcript_alignment", engine::runtime::ArtifactKind::TranscriptAlignment},
{"diarization_state", engine::runtime::ArtifactKind::DiarizationState},
{"vad_state", engine::runtime::ArtifactKind::VadState},
{"custom", engine::runtime::ArtifactKind::Custom},
};
constexpr bool artifact_kinds_in_enum_order() {
for (size_t index = 0; index < std::size(kArtifactKinds); ++index) {
if (kArtifactKinds[index].kind != static_cast<engine::runtime::ArtifactKind>(index)) {
return false;
}
}
return true;
}
// Custom is the last ArtifactKind, so a kind added to the enum without a name
// here fails to build instead of being turned away as unknown.
static_assert(
std::size(kArtifactKinds) == static_cast<size_t>(engine::runtime::ArtifactKind::Custom) + 1 &&
artifact_kinds_in_enum_order(),
"kArtifactKinds must name every ArtifactKind, in enum order");
bool is_artifact_field(const std::string & key) {
for (const char * field : {"id", "kind", "payload", "path", "meta"}) {
if (key == field) {
return true;
}
}
return false;
}
engine::runtime::ArtifactKind parse_artifact_kind(const std::string & name, const std::string & where) {
for (const auto & entry : kArtifactKinds) {
if (name == entry.name) {
return entry.kind;
}
}
std::string names;
for (const auto & entry : kArtifactKinds) {
names += (names.empty() ? "" : ", ") + std::string(entry.name);
}
throw InvalidRequestError(where + ": unknown kind '" + name + "', expected one of " + names);
}
// A field that is absent or null reads as not given.
const engine::io::json::Value * artifact_field(
const engine::io::json::Value & entry,
const std::string & key) {
const auto * value = entry.find(key);
return value == nullptr || value->is_null() ? nullptr : value;
}
std::string artifact_string(
const engine::io::json::Value & entry,
const std::string & key,
const std::string & where) {
const auto * value = artifact_field(entry, key);
if (value == nullptr || !value->is_string() || value->as_string().empty()) {
throw InvalidRequestError(where + ": " + key + " must be a non-empty string");
}
return value->as_string();
}
[[noreturn]] void throw_artifact_limit(const std::string & where) {
throw InvalidRequestError(
where + ": the request's artifact payloads total more than " +
std::to_string(kMaxRequestArtifactBytes) + " bytes");
}
std::vector<std::byte> decode_artifact_payload(
const engine::io::json::Value & payload,
uint64_t budget,
const std::string & where) {
if (!payload.is_string()) {
throw InvalidRequestError(where + ": payload must be a base64 string");
}
// An inline payload is already in memory as part of the request, so the
// server's request body limit bounds this decode.
std::vector<std::byte> bytes;
try {
bytes = minitts::app::base64_decode_bytes(payload.as_string());
} catch (const std::runtime_error & error) {
throw InvalidRequestError(where + ": payload is not valid base64 (" + error.what() + ")");
}
if (bytes.size() > budget) {
throw_artifact_limit(where);
}
return bytes;
}
std::vector<std::byte> read_artifact_file(
const std::filesystem::path & path,
uint64_t budget,
const std::string & where) {
// Regular files only: a FIFO or a device such as /dev/zero has no size to
// check and may never end.
std::error_code error;
if (!std::filesystem::is_regular_file(path, error)) {
throw InvalidRequestError(where + ": path is not a regular file: " + path.string());
}
const auto size = std::filesystem::file_size(path, error);
if (error) {
throw InvalidRequestError(where + ": cannot read the size of " + path.string() + ": " + error.message());
}
if (static_cast<uint64_t>(size) > budget) {
throw_artifact_limit(where);
}
std::ifstream input(path, std::ios::binary);
if (!input) {
throw InvalidRequestError(where + ": failed to open " + path.string());
}
std::vector<std::byte> bytes(static_cast<size_t>(size));
if (!bytes.empty() &&
!input.read(reinterpret_cast<char *>(bytes.data()), static_cast<std::streamsize>(bytes.size()))) {
throw InvalidRequestError(where + ": failed to read " + path.string());
}
return bytes;
}
std::unordered_map<std::string, std::string> artifact_meta(
const engine::io::json::Value & entry,
const std::string & where) {
std::unordered_map<std::string, std::string> meta;
const auto * value = artifact_field(entry, "meta");
if (value == nullptr) {
return meta;
}
if (!value->is_object()) {
throw InvalidRequestError(where + ": meta must be an object");
}
for (const auto & [key, child] : value->as_object()) {
if (!child.is_string() && !child.is_number() && !child.is_bool()) {
throw InvalidRequestError(where + ": meta." + key + " must be a string, number or boolean");
}
meta[key] = json_option_string(child);
}
return meta;
}
} // namespace
bool is_stdin_audio_source(std::string_view audio_arg) {
return audio_arg == "-";
}
engine::runtime::AudioBuffer read_audio_buffer(const std::filesystem::path & path) {
const auto wav = engine::audio::read_wav_f32(path);
return engine::runtime::AudioBuffer{
wav.sample_rate,
wav.channels,
wav.samples,
};
}
engine::runtime::AudioBuffer read_audio_buffer(std::istream & input) {
const auto wav = engine::audio::read_wav_f32(input);
return engine::runtime::AudioBuffer{
wav.sample_rate,
wav.channels,
wav.samples,
};
}
engine::runtime::AudioBuffer read_audio_buffer(std::string_view input) {
const auto wav = engine::audio::read_wav_f32(input);
return engine::runtime::AudioBuffer{
wav.sample_rate,
wav.channels,
wav.samples,
};
}
std::string json_option_string(const engine::io::json::Value & value) {
if (value.is_string()) {
return value.as_string();
}
if (value.is_bool()) {
return value.as_bool() ? "true" : "false";
}
if (value.is_number()) {
const double number = value.as_number();
constexpr double kMaxExactJsonInteger = 9007199254740992.0; // 2^53
if (std::isfinite(number) && std::trunc(number) == number && std::fabs(number) < kMaxExactJsonInteger) {
std::ostringstream out;
out << std::fixed << std::setprecision(0) << number;
return out.str();
}
return engine::io::json::stringify_number(number);
}
return engine::io::json::stringify(value);
}
std::unordered_map<std::string, std::string> json_options_map(const engine::io::json::Value * value) {
std::unordered_map<std::string, std::string> options;
if (value == nullptr || value->is_null()) {
return options;
}
for (const auto & [key, child] : value->as_object()) {
options[key] = json_option_string(child);
}
return options;
}
std::optional<std::string> json_optional_string(
const engine::io::json::Value & object,
const std::string & key) {
const auto * value = object.find(key);
if (value == nullptr || value->is_null()) {
return std::nullopt;
}
return value->as_string();
}
std::optional<float> json_optional_float(
const engine::io::json::Value & object,
const std::string & key) {
const auto * value = object.find(key);
if (value == nullptr || value->is_null()) {
return std::nullopt;
}
return value->as_f32();
}
std::vector<engine::runtime::VoiceArtifact> json_request_artifacts(
const engine::io::json::Value * value,
const std::filesystem::path & base_dir) {
std::vector<engine::runtime::VoiceArtifact> artifacts;
if (value == nullptr || value->is_null()) {
return artifacts;
}
if (!value->is_array()) {
throw InvalidRequestError("artifacts must be an array of artifact objects");
}
uint64_t total_bytes = 0;
const auto & entries = value->as_array();
for (size_t index = 0; index < entries.size(); ++index) {
const auto & entry = entries[index];
std::string where = "artifacts[" + std::to_string(index) + "]";
if (!entry.is_object()) {
throw InvalidRequestError(where + " must be an object with id, kind, and payload or path");
}
for (const auto & field : entry.as_object()) {
if (!is_artifact_field(field.first)) {
throw InvalidRequestError(
where + ": unknown field '" + field.first + "', an artifact has id, kind, payload or path, and meta");
}
}
engine::runtime::VoiceArtifact artifact;
artifact.id = artifact_string(entry, "id", where);
where += " (" + artifact.id + ")";
const auto * kind = artifact_field(entry, "kind");
if (kind == nullptr || !kind->is_string()) {
throw InvalidRequestError(where + ": kind must be a string");
}
artifact.kind = parse_artifact_kind(kind->as_string(), where);
const auto * payload = artifact_field(entry, "payload");
const bool has_path = artifact_field(entry, "path") != nullptr;
if ((payload != nullptr) == has_path) {
throw InvalidRequestError(
where + ": give exactly one of payload (base64) and path (a file holding the payload)");
}
const uint64_t budget = kMaxRequestArtifactBytes - total_bytes;
artifact.payload = payload != nullptr
? decode_artifact_payload(*payload, budget, where)
: read_artifact_file(resolve_case_path(base_dir, artifact_string(entry, "path", where)), budget, where);
total_bytes += artifact.payload.size();
artifact.meta = artifact_meta(entry, where);
artifacts.push_back(std::move(artifact));
}
return artifacts;
}
engine::runtime::TaskRequest build_request_from_json(
const engine::io::json::Value & value,
const std::filesystem::path & base_dir) {
engine::runtime::TaskRequest request;
const std::string language = json_optional_string(value, "language").value_or("");
if (const auto text = json_optional_string(value, "text")) {
request.text_input = engine::runtime::Transcript{*text, language};
}
if (const auto audio = json_optional_string(value, "audio")) {
request.audio_input = read_audio_buffer(resolve_case_path(base_dir, *audio));
}
engine::runtime::VoiceCondition voice;
bool has_voice = false;
if (const auto voice_id = json_optional_string(value, "voice_id")) {
engine::runtime::VoiceReference reference;
reference.cached_voice_id = *voice_id;
voice.speaker = std::move(reference);
has_voice = true;
}
if (const auto voice_ref = json_optional_string(value, "voice_ref")) {
if (!voice.speaker.has_value()) {
voice.speaker = engine::runtime::VoiceReference{};
}
voice.speaker->audio = read_audio_buffer(resolve_case_path(base_dir, *voice_ref));
has_voice = true;
}
engine::runtime::StyleCondition style;
if (const auto style_language = json_optional_string(value, "style_language")) {
style.language = *style_language;
has_voice = true;
}
if (const auto emotion = json_optional_string(value, "emotion")) {
style.emotion = *emotion;
has_voice = true;
}
if (const auto speaking_rate = json_optional_float(value, "speaking_rate")) {
style.speaking_rate = *speaking_rate;
has_voice = true;
}
if (const auto pitch_shift = json_optional_float(value, "pitch_shift")) {
style.pitch_shift = *pitch_shift;
has_voice = true;
}
if (const auto energy_scale = json_optional_float(value, "energy_scale")) {
style.energy_scale = *energy_scale;
has_voice = true;
}
style.tags = json_options_map(value.find("style_tags"));
if (!style.tags.empty()) {
has_voice = true;
}
if (style.language.has_value() || style.emotion.has_value() || style.speaking_rate.has_value() ||
style.pitch_shift.has_value() || style.energy_scale.has_value() || !style.tags.empty()) {
voice.style = std::move(style);
}
if (has_voice) {
request.voice = std::move(voice);
}
request.input_artifacts = json_request_artifacts(value.find("artifacts"), base_dir);
request.options = json_options_map(value.find("options"));
if (!language.empty()) {
set_option(request.options, "language", language);
}
if (const auto route = json_optional_string(value, "task_route")) {
set_option(request.options, "route", *route);
}
if (const auto route = json_optional_string(value, "route")) {
set_option(request.options, "route", *route);
}
if (const auto source_audio = json_optional_string(value, "source_audio")) {
set_option(request.options, "source_audio", path_arg_string(resolve_case_path(base_dir, *source_audio)));
}
if (const auto target_voice = json_optional_string(value, "target_voice")) {
set_option(request.options, "target_voice", path_arg_string(resolve_case_path(base_dir, *target_voice)));
}
if (const auto prosody_ref = json_optional_string(value, "prosody_ref")) {
set_option(request.options, "prosody_ref", path_arg_string(resolve_case_path(base_dir, *prosody_ref)));
}
if (const auto style_ref = json_optional_string(value, "style_ref")) {
set_option(request.options, "style_ref", path_arg_string(resolve_case_path(base_dir, *style_ref)));
}
if (const auto target_text = json_optional_string(value, "target_text")) {
set_option(request.options, "target_text", *target_text);
}
if (const auto style_ref_text = json_optional_string(value, "style_ref_text")) {
set_option(request.options, "style_ref_text", *style_ref_text);
}
if (const auto lyrics = json_optional_string(value, "lyrics")) {
set_option(request.options, "lyrics", *lyrics);
}
if (const auto track_name = json_optional_string(value, "track_name")) {
set_option(request.options, "track_name", *track_name);
}
if (const auto speaker = json_optional_string(value, "speaker")) {
set_option(request.options, "speaker", *speaker);
}
if (const auto duration_seconds = json_optional_float(value, "duration_seconds")) {
set_option(request.options, "duration_seconds", std::to_string(*duration_seconds));
}
if (const auto repaint_start = json_optional_float(value, "repaint_start")) {
set_option(request.options, "repainting_start", std::to_string(*repaint_start));
}
if (const auto repaint_end = json_optional_float(value, "repaint_end")) {
set_option(request.options, "repainting_end", std::to_string(*repaint_end));
}
if (const auto repaint_mode = json_optional_string(value, "repaint_mode")) {
set_option(request.options, "repaint_mode", *repaint_mode);
}
if (const auto repaint_strength = json_optional_float(value, "repaint_strength")) {
set_option(request.options, "repaint_strength", std::to_string(*repaint_strength));
}
set_option_from_json_field(request.options, value, "seed", "seed");
set_option_from_json_field(request.options, value, "max_tokens", "max_tokens");
set_option_from_json_field(request.options, value, "max_steps", "max_steps");
set_option_from_json_field(request.options, value, "temperature", "temperature");
set_option_from_json_field(request.options, value, "top_k", "top_k");
set_option_from_json_field(request.options, value, "top_p", "top_p");
set_option_from_json_field(request.options, value, "repetition_penalty", "repetition_penalty");
set_option_from_json_field(request.options, value, "do_sample", "do_sample");
set_option_from_json_field(request.options, value, "num_beams", "num_beams");
set_option_from_json_field(request.options, value, "guidance_scale", "guidance_scale");
set_option_from_json_field(request.options, value, "num_inference_steps", "num_inference_steps");
set_option_from_json_field(request.options, value, "text_chunk_size", "text_chunk_size");
set_option_from_json_field(request.options, value, "text_chunk_mode", "text_chunk_mode");
set_option_from_json_field(request.options, value, "audio_chunk_seconds", "audio_chunk_seconds");
set_option_from_json_field(request.options, value, "audio_chunk_mode", "audio_chunk_mode");
set_option_from_json_field(request.options, value, "return_timestamps", "return_timestamps");
set_option_from_json_field(request.options, value, "use_prosody_code", "use_prosody_code");
set_option_from_json_field(request.options, value, "predict_target_prosody", "predict_target_prosody");
set_option_from_json_field(request.options, value, "use_pitch_shift", "use_pitch_shift");
set_option_from_json_field(request.options, value, "source_shift_steps", "source_shift_steps");
set_option_from_json_field(request.options, value, "prosody_shift_steps", "prosody_shift_steps");
set_option_from_json_field(request.options, value, "style_shift_steps", "style_shift_steps");
set_option_from_json_field(request.options, value, "target_duration_seconds", "target_duration_seconds");
set_option_from_json_field(request.options, value, "reference_duration_seconds", "reference_duration_seconds");
if (const auto reference_text = json_optional_string(value, "reference_text")) {
set_option(request.options, "reference_text", *reference_text);
}
if (const auto instruct = json_optional_string(value, "instruct")) {
set_option(request.options, "instruct", *instruct);
}
return request;
}
engine::runtime::TaskRequest build_request_from_cli(int argc, char ** argv) {
engine::runtime::TaskRequest request;
const auto language = find_arg(argc, argv, "--language").value_or("");
if (const auto text = find_arg(argc, argv, "--text")) {
request.text_input = engine::runtime::Transcript{*text, language};
}
// Read outside the branch below: only a live stdin source uses them, but an option the CLI
// never looks up cannot be told apart from a misspelling.
const int input_rate = parse_int_arg(argc, argv, "--input-rate", 16000);
const int input_channels = parse_int_arg(argc, argv, "--input-channels", 1);
if (const auto audio_path = find_arg(argc, argv, "--audio")) {
if (is_stdin_audio_source(*audio_path)) {
// Live PCM arrives chunk by chunk, so only the format contract is known up front.
// The samples stay empty; the streaming driver pulls them from stdin instead.
request.audio_input = engine::runtime::AudioBuffer{input_rate, input_channels, {}};
} else {
request.audio_input = read_audio_buffer(std::filesystem::path(*audio_path));
}
}
engine::runtime::VoiceCondition voice;
bool has_voice = false;
if (const auto voice_id = find_arg(argc, argv, "--voice-id")) {
engine::runtime::VoiceReference reference;
reference.cached_voice_id = *voice_id;
voice.speaker = std::move(reference);
has_voice = true;
}
if (const auto voice_ref = find_arg(argc, argv, "--voice-ref")) {
if (!voice.speaker.has_value()) {
voice.speaker = engine::runtime::VoiceReference{};
}
voice.speaker->audio = read_audio_buffer(std::filesystem::path(*voice_ref));
has_voice = true;
}
engine::runtime::StyleCondition style;
if (const auto style_language = find_arg(argc, argv, "--style-language")) {
style.language = *style_language;
has_voice = true;
}
if (const auto emotion = find_arg(argc, argv, "--emotion")) {
style.emotion = *emotion;
has_voice = true;
}
if (const auto speaking_rate = parse_optional_float_arg(argc, argv, "--speaking-rate")) {
style.speaking_rate = *speaking_rate;
has_voice = true;
}
if (const auto pitch_shift = parse_optional_float_arg(argc, argv, "--pitch-shift")) {
style.pitch_shift = *pitch_shift;
has_voice = true;
}
if (const auto energy_scale = parse_optional_float_arg(argc, argv, "--energy-scale")) {
style.energy_scale = *energy_scale;
has_voice = true;
}
style.tags = collect_key_value_args(argc, argv, "--style-tag");
if (!style.tags.empty()) {
has_voice = true;
}
if (style.language.has_value() || style.emotion.has_value() || style.speaking_rate.has_value() ||
style.pitch_shift.has_value() || style.energy_scale.has_value() || !style.tags.empty()) {
voice.style = std::move(style);
}
if (has_voice) {
request.voice = std::move(voice);
}
request.options = collect_key_value_args(argc, argv, "--request-option");
if (!language.empty()) {
set_option(request.options, "language", language);
}
if (const auto route = find_arg(argc, argv, "--task-route")) {
set_option(request.options, "route", *route);
}
if (const auto source_audio = find_arg(argc, argv, "--source-audio")) {
set_option(request.options, "source_audio", *source_audio);
}
if (const auto target_voice = find_arg(argc, argv, "--target-voice")) {
set_option(request.options, "target_voice", *target_voice);
}
if (const auto prosody_ref = find_arg(argc, argv, "--prosody-ref")) {
set_option(request.options, "prosody_ref", *prosody_ref);
}
if (const auto style_ref = find_arg(argc, argv, "--style-ref")) {
set_option(request.options, "style_ref", *style_ref);
}
set_option_from_arg(argc, argv, "--target-text", "target_text", request.options);
set_option_from_arg(argc, argv, "--style-ref-text", "style_ref_text", request.options);
set_option_from_arg(argc, argv, "--lyrics", "lyrics", request.options);
set_option_from_arg(argc, argv, "--track-name", "track_name", request.options);
set_option_from_arg(argc, argv, "--speaker", "speaker", request.options);
set_option_from_arg(argc, argv, "--duration-seconds", "duration_sec", request.options);
set_option_from_arg(argc, argv, "--repaint-start", "repainting_start", request.options);
set_option_from_arg(argc, argv, "--repaint-end", "repainting_end", request.options);
set_option_from_arg(argc, argv, "--repaint-mode", "repaint_mode", request.options);
set_option_from_arg(argc, argv, "--repaint-strength", "repaint_strength", request.options);
set_option_from_arg(argc, argv, "--seed", "seed", request.options);
set_option_from_arg(argc, argv, "--max-tokens", "max_tokens", request.options);
set_option_from_arg(argc, argv, "--max-steps", "max_steps", request.options);
set_option_from_arg(argc, argv, "--temperature", "temperature", request.options);
set_option_from_arg(argc, argv, "--top-k", "top_k", request.options);
set_option_from_arg(argc, argv, "--top-p", "top_p", request.options);
set_option_from_arg(argc, argv, "--repetition-penalty", "repetition_penalty", request.options);
set_option_from_arg(argc, argv, "--do-sample", "do_sample", request.options);
set_option_from_arg(argc, argv, "--num-beams", "num_beams", request.options);
set_option_from_arg(argc, argv, "--guidance-scale", "guidance_scale", request.options);
set_option_from_arg(argc, argv, "--num-inference-steps", "num_inference_steps", request.options);
set_option_from_arg(argc, argv, "--text-chunk-size", "text_chunk_size", request.options);
set_option_from_arg(argc, argv, "--text-chunk-mode", "text_chunk_mode", request.options);
set_option_from_arg(argc, argv, "--audio-chunk-seconds", "audio_chunk_duration_sec", request.options);
set_option_from_arg(argc, argv, "--audio-chunk-mode", "audio_chunk_mode", request.options);
set_option_from_arg(argc, argv, "--use-prosody-code", "use_prosody_code", request.options);
set_option_from_arg(argc, argv, "--predict-target-prosody", "predict_target_prosody", request.options);
set_option_from_arg(argc, argv, "--use-pitch-shift", "use_pitch_shift", request.options);
set_option_from_arg(argc, argv, "--source-shift-steps", "source_shift_steps", request.options);
set_option_from_arg(argc, argv, "--prosody-shift-steps", "prosody_shift_steps", request.options);
set_option_from_arg(argc, argv, "--style-shift-steps", "style_shift_steps", request.options);
set_option_from_arg(argc, argv, "--target-duration-seconds", "target_duration_seconds", request.options);
set_option_from_arg(argc, argv, "--reference-duration-seconds", "reference_duration_seconds", request.options);
if (const auto reference_text = find_arg(argc, argv, "--reference-text")) {
set_option(request.options, "reference_text", *reference_text);
}
if (const auto instruct = find_arg(argc, argv, "--instruct")) {
set_option(request.options, "instruct", *instruct);
}
return request;
}
} // namespace minitts::cli

48
app/cli/request.h Normal file
View file

@ -0,0 +1,48 @@
#pragma once
#include "engine/framework/io/json.h"
#include "engine/framework/runtime/errors.h"
#include "engine/framework/runtime/session.h"
#include <cstdint>
#include <filesystem>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace minitts::cli {
// Most artifact payload bytes one request may carry, inline and from files
// together: the server's default max_request_body_bytes (2 GiB).
inline constexpr uint64_t kMaxRequestArtifactBytes = 2ull * 1024ull * 1024ull * 1024ull;
// True when --audio selects raw PCM on stdin ("-") rather than a file path.
bool is_stdin_audio_source(std::string_view audio_arg);
engine::runtime::AudioBuffer read_audio_buffer(const std::filesystem::path & path);
engine::runtime::AudioBuffer read_audio_buffer(std::istream & path);
engine::runtime::AudioBuffer read_audio_buffer(std::string_view input);
std::string json_option_string(const engine::io::json::Value & value);
std::unordered_map<std::string, std::string> json_options_map(const engine::io::json::Value * value);
std::optional<std::string> json_optional_string(
const engine::io::json::Value & object,
const std::string & key);
std::optional<float> json_optional_float(
const engine::io::json::Value & object,
const std::string & key);
// The request's `artifacts` array, in order, as TaskRequest::input_artifacts.
// Each entry has the shape the server writes a result's artifacts in: `id`,
// `kind`, `payload` (base64, or a data URI) and optional `meta` (string,
// number or boolean values). `path` may replace `payload`: the payload is then
// that file's bytes, resolved against base_dir. Throws
// engine::runtime::InvalidRequestError, which the server answers with 400.
std::vector<engine::runtime::VoiceArtifact> json_request_artifacts(
const engine::io::json::Value * value,
const std::filesystem::path & base_dir);
engine::runtime::TaskRequest build_request_from_json(
const engine::io::json::Value & value,
const std::filesystem::path & base_dir);
engine::runtime::TaskRequest build_request_from_cli(int argc, char ** argv);
} // namespace minitts::cli

114
app/common/base64.cpp Normal file
View file

@ -0,0 +1,114 @@
#include "base64.h"
#include <stdexcept>
namespace minitts::app {
namespace {
constexpr char kAlphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
int decode_char(char c) {
if (c >= 'A' && c <= 'Z') {
return c - 'A';
}
if (c >= 'a' && c <= 'z') {
return c - 'a' + 26;
}
if (c >= '0' && c <= '9') {
return c - '0' + 52;
}
if (c == '+') {
return 62;
}
if (c == '/') {
return 63;
}
return -1;
}
} // namespace
std::string base64_encode(const uint8_t * data, size_t size) {
std::string out;
out.reserve(((size + 2) / 3) * 4);
for (size_t i = 0; i < size; i += 3) {
const uint32_t b0 = data[i];
const uint32_t b1 = i + 1 < size ? data[i + 1] : 0;
const uint32_t b2 = i + 2 < size ? data[i + 2] : 0;
const uint32_t chunk = (b0 << 16) | (b1 << 8) | b2;
out.push_back(kAlphabet[(chunk >> 18) & 0x3f]);
out.push_back(kAlphabet[(chunk >> 12) & 0x3f]);
out.push_back(i + 1 < size ? kAlphabet[(chunk >> 6) & 0x3f] : '=');
out.push_back(i + 2 < size ? kAlphabet[chunk & 0x3f] : '=');
}
return out;
}
std::string base64_encode(const std::vector<uint8_t> & bytes) {
return base64_encode(bytes.data(), bytes.size());
}
std::string base64_encode(const std::vector<std::byte> & bytes) {
return base64_encode(reinterpret_cast<const uint8_t *>(bytes.data()), bytes.size());
}
namespace {
template <typename Byte>
std::vector<Byte> decode(std::string_view input) {
if (input.rfind("data:", 0) == 0) {
const auto comma = input.find(',');
if (comma == std::string_view::npos || input.find(";base64") == std::string_view::npos) {
throw std::runtime_error("malformed base64 data URI");
}
input = input.substr(comma + 1);
}
std::vector<Byte> out;
out.reserve((input.size() / 4) * 3);
uint32_t buffer = 0;
int bits = 0;
int padding = 0;
for (const char c : input) {
if (c == ' ' || c == '\t' || c == '\r' || c == '\n') {
continue;
}
if (c == '=') {
++padding;
if (padding > 2) {
throw std::runtime_error("malformed base64 payload: excess padding");
}
continue;
}
if (padding > 0) {
throw std::runtime_error("malformed base64 payload: data after padding");
}
const int value = decode_char(c);
if (value < 0) {
throw std::runtime_error("malformed base64 payload: invalid character");
}
buffer = (buffer << 6) | static_cast<uint32_t>(value);
bits += 6;
if (bits >= 8) {
bits -= 8;
out.push_back(static_cast<Byte>((buffer >> bits) & 0xff));
}
}
if (bits >= 6) {
throw std::runtime_error("malformed base64 payload: truncated quartet");
}
return out;
}
} // namespace
std::vector<uint8_t> base64_decode(std::string_view input) {
return decode<uint8_t>(input);
}
std::vector<std::byte> base64_decode_bytes(std::string_view input) {
return decode<std::byte>(input);
}
} // namespace minitts::app

21
app/common/base64.h Normal file
View file

@ -0,0 +1,21 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
namespace minitts::app {
std::string base64_encode(const uint8_t * data, size_t size);
std::string base64_encode(const std::vector<uint8_t> & bytes);
std::string base64_encode(const std::vector<std::byte> & bytes);
// Decodes a base64 payload. Accepts an optional "data:<mime>;base64," prefix so
// clients can send data URIs verbatim. Throws std::runtime_error on malformed input.
std::vector<uint8_t> base64_decode(std::string_view input);
// The same, as std::byte, for payloads that are kept as such.
std::vector<std::byte> base64_decode_bytes(std::string_view input);
} // namespace minitts::app

65
app/common/build_info.cpp Normal file
View file

@ -0,0 +1,65 @@
#include "build_info.h"
#include "build_info_config.h"
#include <ostream>
#include <string>
namespace minitts::app {
namespace {
std::string build_type() {
std::string value = AUDIOCPP_BUILD_TYPE_STRING;
if (value.empty()) {
value = "unknown";
}
return value;
}
std::string compiler_name() {
const std::string id = AUDIOCPP_COMPILER_ID_STRING;
const std::string version = AUDIOCPP_COMPILER_VERSION_STRING;
if (id == "GNU") {
return "gcc " + version;
}
if (id == "Clang") {
return "clang " + version;
}
if (id == "AppleClang") {
return "apple-clang " + version;
}
if (id == "MSVC") {
return "msvc " + version;
}
return id.empty() ? "unknown" : id + " " + version;
}
} // namespace
const char * build_version() {
return AUDIOCPP_VERSION_STRING;
}
const char * build_git_sha() {
return AUDIOCPP_GIT_SHA_STRING;
}
const char * build_git_date() {
return AUDIOCPP_GIT_DATE_STRING;
}
void print_build_info(std::ostream & out) {
out << "audio.cpp " << AUDIOCPP_VERSION_STRING << "\n"
<< "git: " << AUDIOCPP_GIT_SHA_STRING << " " << AUDIOCPP_GIT_DATE_STRING << "\n"
<< "build: " << build_type() << ", " << compiler_name() << ", "
<< AUDIOCPP_SYSTEM_NAME_STRING << " " << AUDIOCPP_SYSTEM_PROCESSOR_STRING << "\n"
<< "backends: " << AUDIOCPP_BUILD_BACKENDS_STRING << "\n";
}
void print_build_info_summary(std::ostream & out) {
out << "audio.cpp " << AUDIOCPP_VERSION_STRING
<< " (git " << AUDIOCPP_GIT_SHA_STRING << ", " << build_type() << ")"
<< " [backends: " << AUDIOCPP_BUILD_BACKENDS_STRING << "]\n";
}
} // namespace minitts::app

14
app/common/build_info.h Normal file
View file

@ -0,0 +1,14 @@
#pragma once
#include <iosfwd>
namespace minitts::app {
const char * build_version();
const char * build_git_sha();
const char * build_git_date();
void print_build_info(std::ostream & out);
void print_build_info_summary(std::ostream & out);
} // namespace minitts::app

View file

@ -0,0 +1,11 @@
#pragma once
#define AUDIOCPP_VERSION_STRING "@AUDIOCPP_VERSION@"
#define AUDIOCPP_GIT_SHA_STRING "@AUDIOCPP_GIT_SHA@"
#define AUDIOCPP_GIT_DATE_STRING "@AUDIOCPP_GIT_DATE@"
#define AUDIOCPP_BUILD_TYPE_STRING "@CMAKE_BUILD_TYPE@"
#define AUDIOCPP_COMPILER_ID_STRING "@CMAKE_CXX_COMPILER_ID@"
#define AUDIOCPP_COMPILER_VERSION_STRING "@CMAKE_CXX_COMPILER_VERSION@"
#define AUDIOCPP_SYSTEM_NAME_STRING "@CMAKE_SYSTEM_NAME@"
#define AUDIOCPP_SYSTEM_PROCESSOR_STRING "@CMAKE_SYSTEM_PROCESSOR@"
#define AUDIOCPP_BUILD_BACKENDS_STRING "@AUDIOCPP_BUILD_BACKENDS@"

847
app/gguf/main.cpp Normal file
View file

@ -0,0 +1,847 @@
#include "engine/framework/assets/tensor_source.h"
#include "engine/framework/assets/weight_adapter.h"
#include "engine/framework/debug/trace.h"
#include "engine/framework/io/filesystem.h"
#include "engine/framework/io/json.h"
#include "engine/framework/runtime/options.h"
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <iostream>
#include <iterator>
#include <optional>
#include <set>
#include <sstream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace {
namespace json = engine::io::json;
struct PackageSpecCandidate {
engine::assets::GgufEmbeddedModelSpec spec;
std::string origin;
int priority = 0;
};
const std::unordered_map<std::string, std::string_view> & converter_model_specs() {
static const std::unordered_map<std::string, std::string_view> specs = {
#include "gguf_converter_model_specs.inc"
};
return specs;
}
std::string lower_ascii(std::string value) {
std::transform(value.begin(), value.end(), value.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return value;
}
bool is_gguf_path(const std::filesystem::path & path) {
return lower_ascii(path.extension().string()) == ".gguf";
}
// A GGUF this tool wrote carries its conversion namespaces in the tensor names
// ("<namespace>/<tensor>"), so re-converting one — requantising a published
// bf16 package to q8_0, say — can recover them instead of asking the caller to
// spell out namespaces the file already knows.
std::set<std::string> gguf_tensor_namespaces(const std::filesystem::path & path) {
std::set<std::string> namespaces;
const auto source = engine::assets::open_tensor_source(path);
for (const auto & tensor : source->tensors()) {
const auto separator = tensor.name.find('/');
namespaces.insert(separator == std::string::npos ? std::string() : tensor.name.substr(0, separator));
}
return namespaces;
}
std::set<std::string> input_namespaces(const engine::assets::TensorSourceInput & input) {
if (!input.tensor_prefix.empty())
return {input.tensor_prefix};
if (is_gguf_path(input.path)) {
auto namespaces = gguf_tensor_namespaces(input.path);
if (!namespaces.empty())
return namespaces;
}
return {std::string()};
}
bool excluded_sidecar(const std::filesystem::path & path, const std::filesystem::path & output) {
const std::string extension = lower_ascii(path.extension().string());
return extension == ".safetensors" || extension == ".gguf" || extension == ".bin" || extension == ".pt" ||
extension == ".pth" ||
std::filesystem::weakly_canonical(path) == std::filesystem::weakly_canonical(output) ||
std::filesystem::file_size(path) > 64u * 1024u * 1024u;
}
std::set<std::string>
planned_sidecar_destinations(const std::filesystem::path & root, const std::filesystem::path & output,
const std::vector<engine::assets::GgufEmbeddedFile> & explicit_sidecars,
bool embed_sidecars) {
std::set<std::string> destinations;
std::error_code error;
for (std::filesystem::recursive_directory_iterator it(root, error), end; !error && it != end; it.increment(error)) {
if (it->is_directory() && (it->path().filename() == ".cache" || it->path().filename() == ".git")) {
it.disable_recursion_pending();
continue;
}
if (!it->is_regular_file() ||
(embed_sidecars && excluded_sidecar(it->path(), output)) ||
(!embed_sidecars && std::filesystem::weakly_canonical(it->path()) ==
std::filesystem::weakly_canonical(output)))
continue;
destinations.insert(std::filesystem::relative(it->path(), root).lexically_normal().generic_string());
}
if (error)
throw std::runtime_error("failed to enumerate model sidecars: " + error.message());
std::set<std::string> explicit_destinations;
for (const auto & sidecar : explicit_sidecars) {
const auto destination = sidecar.destination.lexically_normal();
if (!engine::io::is_existing_file(sidecar.source_path)) {
throw std::runtime_error("embedded sidecar source does not exist: " + sidecar.source_path.string());
}
if (destination.empty() || destination.is_absolute() || *destination.begin() == "..") {
throw std::runtime_error("invalid embedded sidecar destination: " + sidecar.destination.string());
}
const auto normalized = destination.generic_string();
if (!explicit_destinations.insert(normalized).second) {
throw std::runtime_error("duplicate embedded sidecar destination: " + destination.generic_string());
}
destinations.insert(normalized);
}
return destinations;
}
PackageSpecCandidate parse_package_spec(const std::string & text, std::string origin, int priority) {
const auto root = json::parse(text);
PackageSpecCandidate candidate;
candidate.spec.family = json::require_string(root, "family");
if (candidate.spec.family.empty())
throw std::runtime_error("model package spec family is empty: " + origin);
const auto & sources = root.require("sources").as_array();
if (sources.empty())
throw std::runtime_error("model package spec has no sources: " + origin);
bool has_gguf = false;
for (const auto & source : sources) {
if (json::require_string(source, "format") == "gguf")
has_gguf = true;
}
if (!has_gguf)
throw std::runtime_error("model package spec has no GGUF source: " + origin);
candidate.spec.json = json::stringify(root);
candidate.origin = std::move(origin);
candidate.priority = priority;
return candidate;
}
void add_candidate(std::vector<PackageSpecCandidate> & candidates, PackageSpecCandidate candidate) {
for (auto & existing : candidates) {
if (existing.spec.family == candidate.spec.family && existing.spec.json == candidate.spec.json) {
if (candidate.priority < existing.priority)
existing = std::move(candidate);
return;
}
}
candidates.push_back(std::move(candidate));
}
void add_spec_file(std::vector<PackageSpecCandidate> & candidates, const std::filesystem::path & path, int priority) {
if (!engine::io::is_existing_file(path))
return;
add_candidate(candidates, parse_package_spec(engine::io::read_text_file(path),
std::filesystem::weakly_canonical(path).string(), priority));
}
void add_spec_path(std::vector<PackageSpecCandidate> & candidates, const std::filesystem::path & path,
const std::optional<std::string> & family, int priority) {
if (engine::io::is_existing_file(path)) {
add_spec_file(candidates, path, priority);
return;
}
if (!engine::io::is_existing_directory(path)) {
throw std::runtime_error("model package spec path does not exist: " + path.string());
}
if (family.has_value()) {
const auto file = path / (*family + ".json");
if (!engine::io::is_existing_file(file)) {
throw std::runtime_error("model package spec not found: " + file.string());
}
add_spec_file(candidates, file, priority);
return;
}
for (const auto & entry : std::filesystem::directory_iterator(path)) {
if (entry.is_regular_file() && lower_ascii(entry.path().extension().string()) == ".json") {
try {
add_spec_file(candidates, entry.path(), priority);
} catch (...) {
// A model_specs directory may contain unrelated JSON files. Only valid
// package specs participate in automatic matching.
}
}
}
}
void add_discovered_spec_directories(std::vector<PackageSpecCandidate> & candidates,
const std::filesystem::path & start,
const std::optional<std::string> & family,
int priority) {
auto cursor = std::filesystem::weakly_canonical(start);
while (!cursor.empty()) {
const auto directory = cursor / "model_specs";
if (engine::io::is_existing_directory(directory)) {
if (!family.has_value() || engine::io::is_existing_file(directory / (*family + ".json"))) {
add_spec_path(candidates, directory, family, priority);
}
}
const auto parent = cursor.parent_path();
if (parent == cursor || parent.empty())
break;
cursor = parent;
}
}
void add_converter_catalog(std::vector<PackageSpecCandidate> & candidates,
const std::optional<std::string> & family,
int priority) {
for (const auto & [catalog_family, text] : converter_model_specs()) {
if (family.has_value() && *family != catalog_family)
continue;
add_candidate(candidates,
parse_package_spec(std::string(text), "converter-catalog:" + catalog_family, priority));
}
}
const json::Value * embedded_spec_value(const json::Value & config) {
if (const auto * value = config.find("audiocpp_model_spec"))
return value;
if (const auto * value = config.find("model_spec"))
return value;
if (const auto * audiocpp = config.find("audiocpp"); audiocpp != nullptr && audiocpp->is_object()) {
if (const auto * value = audiocpp->find("model_spec"))
return value;
if (const auto * value = audiocpp->find("package_spec"))
return value;
}
return nullptr;
}
std::optional<std::string> infer_family_from_config(const json::Value & config) {
if (const auto * value = config.find("audiocpp_family"); value != nullptr && value->is_string()) {
return value->as_string();
}
if (const auto * audiocpp = config.find("audiocpp"); audiocpp != nullptr && audiocpp->is_object()) {
if (const auto * value = audiocpp->find("family"); value != nullptr && value->is_string()) {
return value->as_string();
}
}
const auto * model_type_value = config.find("model_type");
if (model_type_value == nullptr || !model_type_value->is_string())
return std::nullopt;
const std::string model_type = model_type_value->as_string();
if (model_type == "qwen3_asr") {
const auto * thinker = config.find("thinker_config");
if (thinker != nullptr && thinker->is_object()) {
const auto * thinker_type = thinker->find("model_type");
if (thinker_type != nullptr && thinker_type->is_string() &&
thinker_type->as_string() == "qwen3_forced_aligner") {
return "qwen3_forced_aligner";
}
}
return "qwen3_asr";
}
if (model_type == "nemotron3_5_asr")
return "nemotron_asr";
if (model_type == "vibevoice")
return "vibevoice_asr";
if (model_type == "higgs_audio_3")
return "higgs_audio_stt";
if (model_type == "cohere_asr")
return "hviske_asr";
if (model_type == "qwen3_tts")
return "qwen3_tts";
return std::nullopt;
}
std::optional<std::string> add_model_config_spec(std::vector<PackageSpecCandidate> & candidates,
const std::filesystem::path & model_root) {
const auto config_path = model_root / "config.json";
if (!engine::io::is_existing_file(config_path))
return std::nullopt;
const auto config = json::parse_file(config_path);
if (const auto * value = embedded_spec_value(config)) {
if (value->is_object()) {
add_candidate(candidates,
parse_package_spec(json::stringify(*value), config_path.string() + "#model_spec", 1));
} else if (value->is_string()) {
const std::string stored = value->as_string();
const size_t first_content = stored.find_first_not_of(" \t\r\n");
if (first_content == std::string::npos) {
throw std::runtime_error("config.json model_spec string is empty");
}
if (stored[first_content] == '{') {
add_candidate(candidates, parse_package_spec(stored, config_path.string() + "#model_spec", 1));
} else {
const auto stored_path = model_root / stored;
if (!engine::io::is_existing_file(stored_path)) {
throw std::runtime_error("config.json model_spec file does not exist: " + stored_path.string());
}
add_spec_file(candidates, stored_path, 1);
}
} else {
throw std::runtime_error("config.json model_spec must be an object, JSON "
"string, or relative path");
}
}
return infer_family_from_config(config);
}
const json::Value & require_gguf_source(const json::Value & spec) {
for (const auto & source : spec.require("sources").as_array()) {
if (json::require_string(source, "format") == "gguf")
return source;
}
throw std::runtime_error("model package spec has no GGUF source");
}
std::string tensor_prefix(const json::Value & value) {
if (value.is_string())
return {};
const auto * prefix = value.find("prefix");
return prefix == nullptr ? std::string() : prefix->as_string();
}
std::optional<std::string> required_destination(const json::Value & source, const std::string & reference) {
const size_t separator = reference.find(':');
if (separator == std::string::npos || separator == 0) {
throw std::runtime_error("invalid package resource reference: " + reference);
}
const std::string root_id = reference.substr(0, separator);
const std::string relative = reference.substr(separator + 1);
const auto & roots = source.require("roots").as_object();
const auto root = roots.find(root_id);
if (root == roots.end())
throw std::runtime_error("unknown package root: " + root_id);
if (root->second.as_string() == "$gguf")
return std::nullopt;
const auto destination = (std::filesystem::path(root->second.as_string()) / relative).lexically_normal();
if (destination.is_absolute() || destination.empty() || *destination.begin() == "..") {
throw std::runtime_error("unsafe GGUF package resource destination: " + destination.string());
}
return destination.generic_string();
}
std::vector<std::string> validate_candidate(const PackageSpecCandidate & candidate,
const std::set<std::string> & actual_prefixes,
const std::set<std::string> & sidecars,
bool reconversion) {
std::vector<std::string> errors;
try {
const auto spec = json::parse(candidate.spec.json);
const auto & source = require_gguf_source(spec);
std::set<std::string> expected_prefixes;
for (const auto & [unused, value] : source.require("tensors").as_object()) {
(void)unused;
expected_prefixes.insert(tensor_prefix(value));
}
// A namespace in `optional_tensors` is one the family's packages may or
// may not ship — an ACE-Step XL DiT against a turbo-only package. It
// cannot be required of every conversion, but a conversion that does
// supply it is building exactly the package the spec describes, so it
// is not unexpected either.
std::set<std::string> optional_prefixes;
if (const auto * optional_tensors = source.find("optional_tensors");
optional_tensors != nullptr && !optional_tensors->is_null()) {
for (const auto & [unused, value] : optional_tensors->as_object()) {
(void)unused;
optional_prefixes.insert(tensor_prefix(value));
}
}
// Requiring every namespace catches a fresh conversion that forgot an
// input. Re-converting a finished package proves nothing of the sort:
// its namespaces are already exactly what that package ships, and a
// package legitimately built with `--exclude-prefix` (an ACE-Step XL
// GGUF carries no turbo or base DiT) would otherwise be impossible to
// re-quantise even though the runtime loads it happily.
if (!reconversion) {
for (const auto & prefix : expected_prefixes) {
if (actual_prefixes.find(prefix) == actual_prefixes.end()) {
errors.push_back("missing tensor namespace '" + (prefix.empty() ? std::string("<root>") : prefix) +
"'");
}
}
}
for (const auto & prefix : actual_prefixes) {
if (expected_prefixes.find(prefix) == expected_prefixes.end() &&
optional_prefixes.find(prefix) == optional_prefixes.end()) {
errors.push_back("unexpected tensor namespace '" + (prefix.empty() ? std::string("<root>") : prefix) +
"'");
}
}
if (const auto * files = source.find("files")) {
for (const auto & [id, value] : files->as_object()) {
const auto destination = required_destination(source, value.as_string());
if (destination.has_value() && sidecars.find(*destination) == sidecars.end()) {
errors.push_back("missing required sidecar '" + id + "' at " + *destination);
}
}
}
} catch (const std::exception & error) {
errors.push_back(error.what());
}
return errors;
}
struct SidecarPlan {
std::filesystem::path root;
// Carried across by name rather than rediscovered by walking `root`. A
// directory walk applies excluded_sidecar(), which drops .bin, .pt, .pth and
// anything over 64 MiB — right for a source tree full of weights, wrong for
// files that were already chosen as a finished package's sidecars. Kokoro's
// voices are voices/*.bin, so a re-encode silently produced a package with
// none of its 54 voices while reporting embedded_sidecars=true.
std::vector<engine::assets::GgufEmbeddedFile> carried;
// The package being re-emitted, if any. The end-of-run check reads its sidecar
// list again from the file rather than trusting `carried`, so a bug in the
// carrying is caught by the check instead of hidden by it.
std::filesystem::path reused_from;
};
// What the conversion embeds, and where it looks for anything else. The small
// files (configs, tokenizers) that belong in the output: `--root` wins, and so
// does an explicit `--sidecar` set. Otherwise a GGUF input's own embedded copies
// are used — for a re-encode they are exactly the files that package ships,
// where the directory the file happens to sit in is only a guess. Everything
// else keeps using that directory, which is what every safetensors conversion
// does.
SidecarPlan plan_sidecars(const std::filesystem::path & requested,
const std::vector<engine::assets::TensorSourceInput> & inputs,
const std::vector<engine::assets::GgufEmbeddedFile> & explicit_sidecars,
bool embed_sidecars) {
if (!requested.empty())
return {std::filesystem::weakly_canonical(requested), {}, {}};
const auto parent = std::filesystem::weakly_canonical(inputs.front().path.parent_path());
if (!embed_sidecars || !explicit_sidecars.empty())
return {parent, {}, {}};
for (const auto & input : inputs) {
if (!is_gguf_path(input.path) || !engine::assets::gguf_has_embedded_sidecars(input.path))
continue;
const auto materialized = engine::assets::materialize_gguf_sidecars(input.path);
// ⚠ The names come from the GGUF, not from a walk of `materialized`. That
// directory is a cache keyed by path+size+mtime and shared between runs, so
// walking it would carry across anything that happened to be sitting in it.
// The file is the authority on what the package contains.
std::vector<engine::assets::GgufEmbeddedFile> carried;
for (const auto & name : engine::assets::gguf_embedded_sidecar_names(input.path)) {
const auto destination = std::filesystem::path(name).lexically_normal();
const auto source = materialized / destination;
if (!engine::io::is_existing_file(source)) {
throw std::runtime_error("sidecar '" + name + "' embedded in " + input.path.string() +
" did not materialise at " + source.string());
}
carried.push_back({source, destination});
}
std::cerr << "note: reusing the " << carried.size() << " sidecars embedded in " << input.path.string()
<< "; pass --root to override\n";
return {materialized, std::move(carried), input.path};
}
return {parent, {}, {}};
}
PackageSpecCandidate select_package_spec(const std::vector<engine::assets::TensorSourceInput> & inputs,
const std::filesystem::path & model_root, const std::filesystem::path & output,
const std::vector<engine::assets::GgufEmbeddedFile> & explicit_sidecars,
const std::optional<std::filesystem::path> & requested_spec,
std::optional<std::string> family,
bool embed_sidecars) {
// Every input is a GGUF this tool wrote for one family: the conversion is a
// re-encode of a finished package rather than an assembly of a new one.
const bool reconversion =
!inputs.empty() && std::all_of(inputs.begin(), inputs.end(),
[](const engine::assets::TensorSourceInput & input) {
return is_gguf_path(input.path) &&
engine::assets::read_gguf_embedded_model_spec(input.path).has_value();
});
std::vector<PackageSpecCandidate> candidates;
if (requested_spec.has_value()) {
add_spec_path(candidates, *requested_spec, family, 0);
} else {
const size_t config_candidate_count = candidates.size();
// A GGUF input states its own family and package spec. Trusting that is
// both more accurate than guessing from the catalog and safer: without
// it, a single-namespace GGUF matches whichever unrelated family's spec
// happens to accept one unnamed namespace, and the conversion is
// silently stamped with that family.
for (const auto & input : inputs) {
if (!is_gguf_path(input.path))
continue;
const auto embedded = engine::assets::read_gguf_embedded_model_spec(input.path);
if (!embedded.has_value())
continue;
add_candidate(candidates,
parse_package_spec(embedded->json, "embedded:" + input.path.string(), 0));
if (!family.has_value())
family = embedded->family;
}
const auto config_family = add_model_config_spec(candidates, model_root);
if (!family.has_value())
family = config_family;
// An explicitly requested family that none of those specs describes still
// falls through to the catalog, the way it did before they existed.
const bool describes_requested_family =
std::any_of(candidates.begin(), candidates.end(), [&family](const PackageSpecCandidate & candidate) {
return !family.has_value() || candidate.spec.family == *family;
});
if (candidates.size() == config_candidate_count || !describes_requested_family) {
if (engine::io::is_existing_file(model_root / "model_spec.json")) {
add_spec_file(candidates, model_root / "model_spec.json", 2);
}
if (engine::io::is_existing_directory(model_root / "model_specs")) {
add_spec_path(candidates, model_root / "model_specs", family, 2);
}
add_discovered_spec_directories(candidates, std::filesystem::current_path(), family, 3);
add_converter_catalog(candidates, family, 4);
}
}
if (candidates.empty()) {
throw std::runtime_error("no model package spec was found; pass --family/--model-spec, add "
"model_spec to config.json, "
"install model_specs/*.json, or use --allow-missing-model-spec for a "
"non-runtime tensor archive");
}
std::set<std::string> prefixes;
for (const auto & input : inputs) {
for (const auto & prefix : input_namespaces(input)) {
if (!prefixes.insert(prefix).second) {
throw std::runtime_error("duplicate tensor namespace in conversion inputs: '" +
(prefix.empty() ? std::string("<root>") : prefix) + "'");
}
}
}
const auto sidecars = planned_sidecar_destinations(model_root, output, explicit_sidecars, embed_sidecars);
std::vector<std::pair<PackageSpecCandidate, std::vector<std::string>>> matches;
std::vector<std::pair<std::string, std::vector<std::string>>> rejected;
std::optional<int> selected_priority;
for (const auto & candidate : candidates) {
if (family.has_value() && candidate.spec.family != *family)
continue;
if (!selected_priority.has_value() || candidate.priority < *selected_priority)
selected_priority = candidate.priority;
}
if (!selected_priority.has_value()) {
throw std::runtime_error("no model package spec was found for family '" +
(family.has_value() ? *family : std::string("<auto>")) + "'");
}
for (const auto & candidate : candidates) {
if ((family.has_value() && candidate.spec.family != *family) || candidate.priority != *selected_priority)
continue;
auto errors = validate_candidate(candidate, prefixes, sidecars, reconversion);
if (errors.empty())
matches.push_back({candidate, {}});
else
rejected.push_back({candidate.spec.family + " (" + candidate.origin + ")", std::move(errors)});
}
if (matches.empty()) {
std::ostringstream message;
message << "no model package spec matches the conversion inputs";
if (family.has_value())
message << " for family '" << *family << "'";
for (const auto & [name, errors] : rejected) {
message << "\n " << name << ':';
for (const auto & error : errors)
message << "\n - " << error;
}
throw std::runtime_error(message.str());
}
if (matches.size() != 1) {
std::ostringstream message;
message << "multiple model package specs match:";
for (const auto & match : matches)
message << ' ' << match.first.spec.family;
message << "; pass --family or --model-spec explicitly";
throw std::runtime_error(message.str());
}
return matches.front().first;
}
void print_usage() {
std::cout << "Usage: audiocpp_gguf --input [namespace=]<weights> [--input "
"namespace=<weights> ...] "
"--output <weights.gguf> --type "
"<orig|f16|bf16|q8_0|q2_k|q3_k|q4_k|q5_k|q6_k|tq2_1> "
"[--family <family>] [--model-spec <json-or-directory>] "
"[--root <model-dir>] [--sidecar <source>=<destination>] "
"[--bnb-nf4-type q8_0] [--exclude-prefix <logical-prefix>] "
"[--keep-type <tensor-prefix>*=<type>] "
"[--fold-weight-norm <tensor-prefix>*] "
"[--weight-adapter <normalized.safetensors>] [--adapter-strength <float>] "
"[--adapter-merge-mode delta_f32|rankwise_f32|rounded_bf16_delta] "
"[--log] "
"[--overwrite] [--no-sidecars] "
"[--allow-missing-model-spec]\n"
<< " audiocpp_gguf --inspect <model.gguf>\n";
}
} // namespace
int main(int argc, char ** argv) {
try {
std::vector<engine::assets::TensorSourceInput> inputs;
std::vector<engine::assets::GgufEmbeddedFile> sidecars;
std::filesystem::path output;
std::filesystem::path inspect_path;
std::filesystem::path sidecar_root;
std::optional<std::filesystem::path> model_spec_path;
std::optional<std::string> family;
std::string type;
engine::assets::GgufConversionOptions conversion_options;
std::filesystem::path adapter_path;
engine::assets::WeightAdapterReadOptions adapter_options;
adapter_options.require_scaling_metadata = true;
auto adapter_mode = engine::assets::WeightMergeMode::DeltaF32;
bool adapter_options_set = false;
bool overwrite = false;
bool embed_sidecars = true;
bool allow_missing_model_spec = false;
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
if (arg == "--help" || arg == "-h") {
print_usage();
return 0;
}
if (arg == "--log") {
engine::debug::configure_logging({true});
continue;
}
if (arg == "--overwrite") {
overwrite = true;
continue;
}
if (arg == "--no-sidecars") {
embed_sidecars = false;
continue;
}
if (arg == "--allow-missing-model-spec") {
allow_missing_model_spec = true;
continue;
}
if ((arg == "--input" || arg == "--output" || arg == "--type" || arg == "--inspect" || arg == "--root" ||
arg == "--sidecar" || arg == "--family" || arg == "--model-spec" || arg == "--model-spec-override" ||
arg == "--bnb-nf4-type" || arg == "--exclude-prefix" || arg == "--keep-type" ||
arg == "--weight-adapter" || arg == "--adapter-strength" || arg == "--adapter-merge-mode" ||
arg == "--fold-weight-norm") &&
i + 1 < argc) {
const std::string value = argv[++i];
if (arg == "--input") {
const auto separator = value.find('=');
if (separator == std::string::npos)
inputs.push_back({value, ""});
else
inputs.push_back({value.substr(separator + 1), value.substr(0, separator)});
} else if (arg == "--output")
output = value;
else if (arg == "--type")
type = value;
else if (arg == "--weight-adapter")
adapter_path = std::filesystem::u8path(value);
else if (arg == "--adapter-strength") {
adapter_options.strength = *engine::runtime::parse_finite_float_option(
{{"adapter_strength", value}}, {"adapter_strength"});
adapter_options_set = true;
} else if (arg == "--adapter-merge-mode") {
if (value == "delta_f32") adapter_mode = engine::assets::WeightMergeMode::DeltaF32;
else if (value == "rankwise_f32") adapter_mode = engine::assets::WeightMergeMode::RankWiseF32;
else if (value == "rounded_bf16_delta") adapter_mode = engine::assets::WeightMergeMode::RoundedBF16Delta;
else throw std::runtime_error("unsupported adapter merge mode: " + value);
adapter_options_set = true;
}
else if (arg == "--bnb-nf4-type")
conversion_options.bnb_nf4_type = engine::assets::parse_tensor_storage_type(value);
else if (arg == "--exclude-prefix")
conversion_options.excluded_tensor_prefixes.push_back(value);
else if (arg == "--fold-weight-norm")
conversion_options.folded_weight_norm_patterns.push_back(value);
else if (arg == "--inspect")
inspect_path = value;
else if (arg == "--root")
sidecar_root = value;
else if (arg == "--family")
family = value;
else if (arg == "--model-spec" || arg == "--model-spec-override") {
model_spec_path = std::filesystem::path(value);
} else if (arg == "--keep-type") {
const auto separator = value.find('=');
if (separator == std::string::npos || separator == 0 || separator + 1 == value.size()) {
throw std::runtime_error("--keep-type requires <tensor-prefix>*=<type>");
}
conversion_options.type_overrides.push_back({
value.substr(0, separator),
engine::assets::parse_tensor_storage_type(value.substr(separator + 1)),
});
} else {
const auto separator = value.find('=');
if (separator == std::string::npos || separator == 0 || separator + 1 == value.size()) {
throw std::runtime_error("--sidecar requires <source>=<destination>");
}
sidecars.push_back({value.substr(0, separator), value.substr(separator + 1)});
}
continue;
}
throw std::runtime_error("unknown or incomplete argument: " + arg);
}
if (!inspect_path.empty()) {
const auto source = engine::assets::open_tensor_source(inspect_path);
const auto tensors = source->tensors();
size_t scalar_count = 0;
std::set<std::string> namespaces;
for (const auto & tensor : tensors) {
if (tensor.shape.empty())
++scalar_count;
const auto separator = tensor.name.find('/');
if (separator != std::string::npos)
namespaces.insert(tensor.name.substr(0, separator));
}
std::cout << "gguf=" << std::filesystem::weakly_canonical(inspect_path).string() << "\n";
std::cout << "tensors=" << tensors.size() << "\n";
std::cout << "rank0_scalars=" << scalar_count << "\n";
// The count, not just the boolean: a package that lost 54 of its 59
// sidecars still reports true, which is how that went unnoticed.
const auto inspect_sidecars = engine::assets::gguf_embedded_sidecar_names(inspect_path);
std::cout << "embedded_sidecars=" << (inspect_sidecars.empty() ? "false" : "true") << "\n";
std::cout << "embedded_sidecar_count=" << inspect_sidecars.size() << "\n";
const auto model_spec = engine::assets::read_gguf_embedded_model_spec(inspect_path);
std::cout << "embedded_model_spec=" << (model_spec.has_value() ? "true" : "false") << "\n";
if (model_spec.has_value())
std::cout << "model_spec_family=" << model_spec->family << "\n";
for (const auto & value : namespaces)
std::cout << "namespace=" << value << "\n";
return 0;
}
if (inputs.empty() || output.empty() || type.empty()) {
print_usage();
return 2;
}
if (!embed_sidecars && !sidecars.empty()) {
throw std::runtime_error("--sidecar cannot be combined with --no-sidecars");
}
for (const auto & input : inputs) {
if (!std::filesystem::is_regular_file(input.path)) {
throw std::runtime_error("input tensor file does not exist: " + input.path.string());
}
}
if (std::filesystem::exists(output)) {
if (!overwrite) {
throw std::runtime_error("output already exists; pass --overwrite to replace it: " + output.string());
}
}
const auto storage_type = engine::assets::parse_tensor_storage_type(type);
const auto sidecar_plan = plan_sidecars(sidecar_root, inputs, sidecars, embed_sidecars);
const auto & resolved_sidecar_root = sidecar_plan.root;
// Empty unless the input was a GGUF whose sidecars are being reused, which
// plan_sidecars only does when the caller passed no --sidecar of its own,
// so these cannot collide with an explicit destination.
sidecars.insert(sidecars.end(), sidecar_plan.carried.begin(), sidecar_plan.carried.end());
std::optional<engine::assets::GgufEmbeddedModelSpec> embedded_model_spec;
if (!allow_missing_model_spec) {
embedded_model_spec =
select_package_spec(inputs, resolved_sidecar_root, output, sidecars, model_spec_path, family,
embed_sidecars).spec;
} else {
try {
embedded_model_spec =
select_package_spec(inputs, resolved_sidecar_root, output, sidecars, model_spec_path, family,
embed_sidecars).spec;
} catch (const std::exception & warning) {
std::cerr << "warning: creating GGUF without model package spec: " << warning.what() << "\n";
}
}
if (conversion_options.bnb_nf4_type.has_value() &&
*conversion_options.bnb_nf4_type != engine::assets::TensorStorageType::Q8_0) {
throw std::runtime_error("--bnb-nf4-type currently supports q8_0");
}
if (adapter_path.empty() && adapter_options_set) {
throw std::runtime_error("adapter strength/mode requires --weight-adapter");
}
if (!adapter_path.empty()) {
auto adapter = engine::assets::read_weight_adapter(adapter_path, adapter_options);
if (adapter.updates.empty() && adapter.replacements.empty()) {
throw std::runtime_error("weight adapter contains no updates or replacements");
}
for (auto & entry : adapter.updates) {
entry.second.merge_mode = adapter_mode;
}
conversion_options.weight_adapter = std::make_shared<engine::assets::WeightAdapter>(std::move(adapter));
conversion_options.weight_adapter_merge_mode = adapter_mode;
}
engine::assets::convert_tensor_sources_to_gguf(inputs, output, storage_type, overwrite, embed_sidecars,
resolved_sidecar_root, sidecars, embedded_model_spec,
conversion_options);
std::cout << "gguf=" << std::filesystem::weakly_canonical(output).string() << "\n";
std::cout << "weight_type=" << type << "\n";
std::cout << "tensor_sources=" << inputs.size() << "\n";
if (conversion_options.bnb_nf4_type.has_value())
std::cout << "bnb_nf4_type=q8_0\n";
for (const auto & prefix : conversion_options.excluded_tensor_prefixes)
std::cout << "excluded_prefix=" << prefix << "\n";
std::cout << "type_overrides=" << conversion_options.type_overrides.size() << "\n";
std::cout << "folded_weight_norm_patterns=" << conversion_options.folded_weight_norm_patterns.size() << "\n";
// ⚠ READ THE FINISHED FILE BACK AND CHECK IT AGAINST WHAT WAS ASKED FOR.
// This tool used to write a package missing 54 of its 59 sidecars, exit 0,
// and report embedded_sidecars=true -- because "true" was read from the
// output but was too coarse to notice, and the model-spec line was read
// from the in-memory intent rather than from the file at all. A claim
// about the output that is not read from the output is not a check.
const auto written_sidecars = engine::assets::gguf_embedded_sidecar_names(output);
// ⚠ The expected set is read from the INPUT package, not from the plan that
// produced the output. An earlier version of this check compared against
// planned_sidecar_destinations, which is built by the same filtered walk that
// caused the bug -- so it agreed with a run that dropped 54 voices. A check
// derived from the thing it is checking cannot fail.
if (embed_sidecars && !sidecar_plan.reused_from.empty()) {
const auto expected = engine::assets::gguf_embedded_sidecar_names(sidecar_plan.reused_from);
const std::set<std::string> expected_set(expected.begin(), expected.end());
const std::set<std::string> written(written_sidecars.begin(), written_sidecars.end());
std::vector<std::string> missing;
std::set_difference(expected_set.begin(), expected_set.end(), written.begin(), written.end(),
std::back_inserter(missing));
if (!missing.empty()) {
std::ostringstream report;
report << "re-encoding " << sidecar_plan.reused_from.string() << " kept " << written.size()
<< " of its " << expected_set.size() << " sidecars; missing:";
for (size_t i = 0; i < missing.size() && i < 8; ++i)
report << "\n " << missing[i];
if (missing.size() > 8)
report << "\n ... and " << (missing.size() - 8) << " more";
throw std::runtime_error(report.str());
}
}
// Read back, not reported from intent: a spec that failed to embed would
// otherwise still print true.
const auto written_spec = engine::assets::read_gguf_embedded_model_spec(output);
if (embedded_model_spec.has_value() && !written_spec.has_value()) {
throw std::runtime_error("model spec for family '" + embedded_model_spec->family +
"' was not embedded in " + output.string());
}
if (embedded_model_spec.has_value() && written_spec->family != embedded_model_spec->family) {
throw std::runtime_error("embedded model spec family is '" + written_spec->family + "', expected '" +
embedded_model_spec->family + "'");
}
std::cout << "embedded_sidecars=" << (written_sidecars.empty() ? "false" : "true") << "\n";
std::cout << "embedded_sidecar_count=" << written_sidecars.size() << "\n";
std::cout << "embedded_model_spec=" << (written_spec.has_value() ? "true" : "false") << "\n";
if (written_spec.has_value()) {
std::cout << "model_spec_family=" << written_spec->family << "\n";
}
return 0;
} catch (const std::exception & error) {
std::cerr << "error: " << error.what() << "\n";
return 1;
}
}

114
app/model_manager/main.cpp Normal file
View file

@ -0,0 +1,114 @@
#include "engine/framework/package_manager/manager.h"
#include <atomic>
#include <cstdint>
#include <filesystem>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>
#include <string_view>
namespace {
struct Arguments {
std::string command;
std::string package_id;
std::filesystem::path models_root = "models";
std::filesystem::path repository_root = ".";
bool overwrite = false;
bool query_remote = false;
};
void print_usage(std::ostream & output) {
output <<
"audio.cpp native model package manager\n\n"
"Usage:\n"
" audiocpp_model_manager list [--remote] [--models-dir PATH] [--repository-root PATH]\n"
" audiocpp_model_manager info PACKAGE [--remote] [--models-dir PATH] [--repository-root PATH]\n"
" audiocpp_model_manager install PACKAGE [--overwrite] [--models-dir PATH] [--repository-root PATH]\n"
" audiocpp_model_manager clean PACKAGE [--models-dir PATH] [--repository-root PATH]\n"
" audiocpp_model_manager remove PACKAGE [--models-dir PATH] [--repository-root PATH]\n\n"
"The active model_specs catalog is compiled into this executable. An external\n"
"model_specs directory below --repository-root overrides the embedded catalog.\n";
}
std::string require_value(int & index, int argc, char ** argv, std::string_view option) {
if (++index >= argc) throw std::runtime_error(std::string(option) + " requires a value");
return argv[index];
}
Arguments parse_arguments(int argc, char ** argv) {
if (argc < 2) throw std::runtime_error("missing command");
Arguments result;
result.command = argv[1];
if (result.command == "--help" || result.command == "-h" || result.command == "help") return result;
const bool requires_package = result.command == "info" || result.command == "install" ||
result.command == "clean" || result.command == "remove";
int index = 2;
if (requires_package) {
if (index >= argc || argv[index][0] == '-') throw std::runtime_error(result.command + " requires PACKAGE");
result.package_id = argv[index++];
} else if (result.command != "list") {
throw std::runtime_error("unknown command: " + result.command);
}
for (; index < argc; ++index) {
const std::string option = argv[index];
if (option == "--models-dir") result.models_root = require_value(index, argc, argv, option);
else if (option == "--repository-root") result.repository_root = require_value(index, argc, argv, option);
else if (option == "--overwrite") result.overwrite = true;
else if (option == "--remote") result.query_remote = true;
else throw std::runtime_error("unknown option: " + option);
}
return result;
}
} // namespace
int main(int argc, char ** argv) {
try {
const auto arguments = parse_arguments(argc, argv);
if (arguments.command == "--help" || arguments.command == "-h" || arguments.command == "help") {
print_usage(std::cout);
return 0;
}
engine::package_manager::PackageManager manager(
arguments.repository_root, arguments.models_root);
if (arguments.command == "list" || arguments.command == "info") {
// Inventory JSON is the shared machine-readable contract used by the
// server, scripts, and Docker workflows.
std::cout << manager.inventory(
arguments.query_remote,
arguments.command == "info" ? arguments.package_id : std::string{}) << '\n';
return 0;
}
if (arguments.command == "clean") {
std::cout << manager.clean_partial(arguments.package_id) << '\n';
return 0;
}
if (arguments.command == "remove") {
std::cout << manager.remove(arguments.package_id) << '\n';
return 0;
}
auto cancelled = std::make_shared<std::atomic_bool>(false);
uint64_t last_reported = 0;
const auto result = manager.install(
arguments.package_id, arguments.overwrite, cancelled,
[&last_reported](const engine::package_manager::PackageProgress & progress) {
if (progress.downloaded_bytes == last_reported && progress.message.empty()) return;
last_reported = progress.downloaded_bytes;
std::cerr << "downloaded=" << progress.downloaded_bytes
<< " total=" << progress.total_bytes;
if (!progress.message.empty()) std::cerr << " " << progress.message;
std::cerr << '\n';
});
std::cout << result << '\n';
return 0;
} catch (const std::exception & error) {
std::cerr << "audiocpp_model_manager failed: " << error.what() << '\n';
print_usage(std::cerr);
return 1;
}
}

86
app/recipe/cli_recipe.cpp Normal file
View file

@ -0,0 +1,86 @@
#include "cli_recipe.h"
#include "engine/framework/core/load_progress.h"
#include <stdexcept>
#include <utility>
namespace minitts::app {
struct CliRecipeModelExecutor::ModelEntry {
engine::runtime::TaskSpec task;
std::unique_ptr<engine::runtime::ILoadedVoiceModel> model;
std::unique_ptr<engine::runtime::IVoiceTaskSession> session;
engine::runtime::IOfflineVoiceTaskSession * offline = nullptr;
engine::runtime::IStreamingVoiceTaskSession * streaming = nullptr;
};
CliRecipeModelExecutor::CliRecipeModelExecutor(
engine::runtime::ModelRegistry & registry,
CliRecipeOptions options)
: registry_(registry), options_(std::move(options)) {}
CliRecipeModelExecutor::~CliRecipeModelExecutor() = default;
CliRecipeModelExecutor::ModelEntry & CliRecipeModelExecutor::model_for(
std::string_view role_view, const engine::runtime::TaskSpec & task) {
std::lock_guard<std::mutex> lock(models_mutex_);
const std::string role(role_view);
auto found = models_.find(role);
if (found == models_.end()) {
const auto path = options_.model_paths.find(role);
const auto family = options_.model_families.find(role);
if (path == options_.model_paths.end() || family == options_.model_families.end()) {
throw std::runtime_error(
"recipe role '" + role + "' requires both --recipe-model " + role +
"=<path> and --recipe-family " + role + "=<family>");
}
engine::runtime::ModelLoadRequest load_request;
load_request.model_path = path->second;
load_request.model_spec_override = options_.model_spec_override;
load_request.family_hint = family->second;
load_request.options = options_for_recipe_role(options_.load_options, role, {role}, false);
engine::runtime::SessionOptions session_options;
session_options.backend = options_.backend;
session_options.options = options_for_recipe_role(options_.session_options, role, {role}, false);
auto entry = std::make_unique<ModelEntry>();
entry->task = task;
entry->model = registry_.load(load_request);
entry->session = entry->model->create_task_session(task, session_options);
engine::runtime::finish_model_load_trace();
entry->offline = dynamic_cast<engine::runtime::IOfflineVoiceTaskSession *>(entry->session.get());
entry->streaming = dynamic_cast<engine::runtime::IStreamingVoiceTaskSession *>(entry->session.get());
if ((task.mode == engine::runtime::RunMode::Offline && !entry->offline) ||
(task.mode == engine::runtime::RunMode::Streaming && !entry->streaming)) {
throw std::runtime_error("recipe role '" + role + "' does not support its execution mode");
}
found = models_.emplace(role, std::move(entry)).first;
}
if (found->second->task.task != task.task || found->second->task.mode != task.mode) {
throw std::runtime_error("recipe role '" + role + "' was reused with a different task");
}
return *found->second;
}
engine::runtime::TaskResult CliRecipeModelExecutor::run(std::string_view role,
const engine::runtime::TaskSpec & task, const engine::runtime::TaskRequest & request) {
auto & entry = model_for(role, task);
if (!entry.offline) throw std::runtime_error("recipe role requires offline execution");
entry.session->prepare(engine::runtime::build_preparation_request(request));
return entry.offline->run(request);
}
engine::runtime::TaskResult CliRecipeModelExecutor::stream(std::string_view role,
const engine::runtime::TaskSpec & task, const engine::runtime::TaskRequest & request,
const RecipeEventSink & sink, const AudioChunkStream * source) {
auto & entry = model_for(role, task);
if (!entry.streaming) throw std::runtime_error("recipe role requires streaming execution");
entry.session->prepare(engine::runtime::build_preparation_request(request));
return source ? run_streaming_task(*entry.streaming, request, sink, *source)
: run_streaming_task(*entry.streaming, request, sink);
}
} // namespace minitts::app

48
app/recipe/cli_recipe.h Normal file
View file

@ -0,0 +1,48 @@
#pragma once
#include "recipe.h"
#include "engine/framework/core/backend.h"
#include "engine/framework/runtime/registry.h"
#include <filesystem>
#include <optional>
#include <mutex>
#include <string>
#include <unordered_map>
namespace minitts::app {
struct CliRecipeOptions {
std::unordered_map<std::string, std::filesystem::path> model_paths;
std::unordered_map<std::string, std::string> model_families;
std::unordered_map<std::string, std::string> load_options;
std::unordered_map<std::string, std::string> session_options;
std::optional<std::filesystem::path> model_spec_override;
engine::core::BackendConfig backend;
};
class CliRecipeModelExecutor final : public IRecipeModelExecutor {
public:
CliRecipeModelExecutor(engine::runtime::ModelRegistry & registry, CliRecipeOptions options);
~CliRecipeModelExecutor() override;
engine::runtime::TaskResult run(
std::string_view role,
const engine::runtime::TaskSpec & task,
const engine::runtime::TaskRequest & request) override;
engine::runtime::TaskResult stream(std::string_view role,
const engine::runtime::TaskSpec & task, const engine::runtime::TaskRequest & request,
const RecipeEventSink & sink, const AudioChunkStream * source = nullptr) override;
private:
struct ModelEntry;
ModelEntry & model_for(std::string_view role, const engine::runtime::TaskSpec & task);
std::mutex models_mutex_;
engine::runtime::ModelRegistry & registry_;
CliRecipeOptions options_;
std::unordered_map<std::string, std::unique_ptr<ModelEntry>> models_;
};
} // namespace minitts::app

View file

@ -0,0 +1,95 @@
#include "../recipe.h"
#include "engine/framework/runtime/errors.h"
#include "engine/framework/text/chunking.h"
#include <memory>
#include <stdexcept>
#include <utility>
namespace minitts::app {
namespace {
using namespace engine::runtime;
class AnyTtsRecipe final : public IRecipe {
public:
std::string id() const override { return "any-tts"; }
std::string description() const override {
return "Generate speech with any offline TTS model, then clone a reference voice with Tone Color VC";
}
std::vector<RecipeModelRole> roles() const override {
return {
{"tts", {VoiceTaskKind::Tts, RunMode::Offline}},
{"converter", {VoiceTaskKind::VoiceConversion, RunMode::Offline}},
};
}
TaskResult run(
IRecipeModelExecutor & executor,
const TaskRequest & request,
const RecipeEventSink & event_sink) const override {
if (!request.text_input || request.text_input->text.empty()) {
throw InvalidRequestError("any-tts recipe requires text");
}
if (!request.voice || !request.voice->speaker || !request.voice->speaker->audio) {
throw InvalidRequestError("any-tts recipe requires reference speaker audio");
}
const auto chunk_size = engine::text::parse_text_chunk_size_override(request.options).value_or(240);
const auto chunk_mode = engine::text::parse_text_chunk_mode_override(request.options)
.value_or(engine::text::TextChunkMode::Default);
auto chunks = engine::text::split_text_chunks(request.text_input->text, chunk_size, chunk_mode);
TaskResult result;
for (size_t i = 0; i < chunks.size(); ++i) {
TaskRequest synthesis = request;
synthesis.text_input->text = std::move(chunks[i]);
synthesis.options = options_for_recipe_role(
request.options, "tts", {"tts", "converter"}, true);
synthesis.options.erase("text_chunk_size");
synthesis.options.erase("text_chunk_mode");
if (synthesis.voice->speaker->cached_voice_id) {
synthesis.voice->speaker->audio.reset();
} else {
synthesis.voice->speaker.reset();
}
auto generated = executor.run(
"tts", {VoiceTaskKind::Tts, RunMode::Offline}, synthesis);
if (!generated.audio_output) {
throw std::runtime_error("any-tts TTS model returned no audio");
}
TaskRequest conversion;
conversion.audio_input = std::move(generated.audio_output);
conversion.voice = request.voice;
conversion.options = options_for_recipe_role(
request.options, "converter", {"tts", "converter"}, false);
auto converted = executor.run(
"converter", {VoiceTaskKind::VoiceConversion, RunMode::Offline}, conversion);
if (!converted.audio_output) {
throw std::runtime_error("any-tts converter returned no audio");
}
if (!result.audio_output) {
result.audio_output.emplace();
}
append_audio_buffer(*result.audio_output, *converted.audio_output);
if (event_sink) {
StreamEvent event;
event.audio_output = std::move(converted.audio_output);
event.is_final = i + 1 == chunks.size();
event_sink(event);
}
}
return result;
}
};
const RecipePluginRegistration registration([] {
return std::make_unique<AnyTtsRecipe>();
});
} // namespace
} // namespace minitts::app

View file

@ -0,0 +1,77 @@
#include "../recipe.h"
#include "engine/framework/runtime/errors.h"
#include <memory>
#include <stdexcept>
#include <utility>
namespace minitts::app {
namespace {
using namespace engine::runtime;
class CrossLanguageDubbingRecipe final : public IRecipe {
public:
std::string id() const override { return "cross-language-dubbing"; }
std::string description() const override {
return "Translate speech with an ASR translation model and dub it with a target-language TTS model";
}
std::vector<RecipeModelRole> roles() const override {
return {
{"translator", {VoiceTaskKind::Asr, RunMode::Offline}},
{"tts", {VoiceTaskKind::Tts, RunMode::Offline}},
};
}
TaskResult run(
IRecipeModelExecutor & executor,
const TaskRequest & request,
const RecipeEventSink & event_sink) const override {
if (!request.audio_input) {
throw InvalidRequestError("cross-language-dubbing recipe requires input audio");
}
if (request.options.find("translator.target_language") == request.options.end()) {
throw InvalidRequestError(
"cross-language-dubbing recipe requires translator.target_language");
}
TaskRequest translation_request;
translation_request.audio_input = request.audio_input;
translation_request.text_input = request.text_input;
translation_request.options = options_for_recipe_role(
request.options, "translator", {"translator", "tts"}, false);
auto translated = executor.run(
"translator", {VoiceTaskKind::Asr, RunMode::Offline}, translation_request);
if (!translated.text_output || translated.text_output->text.empty()) {
throw std::runtime_error("cross-language-dubbing translator returned no text");
}
TaskRequest synthesis;
synthesis.text_input = Transcript{translated.text_output->text, {}};
synthesis.voice = request.voice;
synthesis.options = options_for_recipe_role(
request.options, "tts", {"translator", "tts"}, true);
auto result = executor.run(
"tts", {VoiceTaskKind::Tts, RunMode::Offline}, synthesis);
if (!result.audio_output) {
throw std::runtime_error("cross-language-dubbing TTS model returned no audio");
}
result.text_output = std::move(translated.text_output);
if (event_sink) {
StreamEvent event;
event.audio_output = result.audio_output;
event.partial_text = result.text_output;
event.is_final = true;
event_sink(event);
}
return result;
}
};
const RecipePluginRegistration registration([] {
return std::make_unique<CrossLanguageDubbingRecipe>();
});
} // namespace
} // namespace minitts::app

View file

@ -0,0 +1,281 @@
#include "../recipe.h"
#include "engine/framework/audio/conversion.h"
#include "engine/framework/audio/streaming_resampler.h"
#include "../streaming_tempo.h"
#include "engine/framework/debug/profiler.h"
#include "engine/framework/runtime/errors.h"
#include "engine/framework/runtime/options.h"
#include "engine/framework/text/chunking.h"
#include <algorithm>
#include <atomic>
#include <condition_variable>
#include <deque>
#include <exception>
#include <iterator>
#include <mutex>
#include <thread>
namespace minitts::app {
namespace {
using namespace engine::runtime;
// Two seconds of PCM bound producer lead. Cancellation releases either blocked side.
class AudioPipe {
public:
explicit AudioPipe(int rate) : limit_(static_cast<size_t>(rate) * 2) {}
void push(std::vector<float> samples) {
size_t offset = 0;
while (offset < samples.size()) {
std::unique_lock<std::mutex> lock(mutex_);
ready_.wait(lock, [&] { return stopped_ || buffered_ < limit_; });
if (stopped_) throw std::runtime_error("streaming avatar audio was cancelled");
const size_t count = std::min(limit_ - buffered_, samples.size() - offset);
chunks_.emplace_back(samples.begin() + offset, samples.begin() + offset + count);
buffered_ += count;
offset += count;
ready_.notify_all();
}
}
bool read(int64_t maximum, std::vector<float> & samples) {
std::unique_lock<std::mutex> lock(mutex_);
ready_.wait(lock, [&] { return stopped_ || error_ || done_ || buffered_; });
if (error_) std::rethrow_exception(error_);
if (stopped_) throw std::runtime_error("streaming avatar audio was cancelled");
if (!buffered_) return false;
const auto count = std::min(static_cast<size_t>(maximum), chunks_.front().size() - offset_);
samples.assign(chunks_.front().begin() + offset_, chunks_.front().begin() + offset_ + count);
offset_ += count;
buffered_ -= count;
if (offset_ == chunks_.front().size()) { chunks_.pop_front(); offset_ = 0; }
ready_.notify_all();
return true;
}
void finish(std::exception_ptr error = {}) {
std::lock_guard<std::mutex> lock(mutex_);
done_ = true; error_ = error; ready_.notify_all();
}
void cancel() {
std::lock_guard<std::mutex> lock(mutex_);
stopped_ = true; ready_.notify_all();
}
private:
std::mutex mutex_;
std::condition_variable ready_;
std::deque<std::vector<float>> chunks_;
size_t limit_, buffered_ = 0, offset_ = 0;
bool done_ = false, stopped_ = false;
std::exception_ptr error_;
};
// Bound the text lead as well as PCM: conversation, synthesis and animation run independently.
class TextPipe {
public:
void push(std::string text) {
std::unique_lock<std::mutex> lock(mutex_);
ready_.wait(lock, [&] { return stopped_ || chunks_.size() < 2; });
if (stopped_) throw std::runtime_error("streaming conversation was cancelled");
chunks_.push_back(std::move(text));
ready_.notify_all();
}
bool read(std::string & text) {
std::unique_lock<std::mutex> lock(mutex_);
ready_.wait(lock, [&] { return stopped_ || error_ || done_ || !chunks_.empty(); });
if (error_) std::rethrow_exception(error_);
if (stopped_) throw std::runtime_error("streaming conversation was cancelled");
if (chunks_.empty()) return false;
text = std::move(chunks_.front()); chunks_.pop_front(); ready_.notify_all();
return true;
}
void finish(std::exception_ptr error = {}) {
std::lock_guard<std::mutex> lock(mutex_);
done_ = true; error_ = error; ready_.notify_all();
}
void cancel() {
std::lock_guard<std::mutex> lock(mutex_);
stopped_ = true; ready_.notify_all();
}
private:
std::mutex mutex_;
std::condition_variable ready_;
std::deque<std::string> chunks_;
bool done_ = false, stopped_ = false;
std::exception_ptr error_;
};
class SpeakingAvatarRecipe final : public IRecipe {
public:
explicit SpeakingAvatarRecipe(bool conversation = false) : conversation_(conversation) {}
std::string id() const override { return conversation_ ? "voice-chat-avatar" : "speaking-avatar"; }
std::string description() const override { return "Stream conversation text, selected-voice speech and avatar animation"; }
std::vector<RecipeModelRole> roles() const override {
std::vector<RecipeModelRole> result{{"tts", {VoiceTaskKind::Tts, RunMode::Streaming}},
{"avatar", {VoiceTaskKind::AudioGeneration, RunMode::Streaming}}};
if (conversation_) result.push_back({"conversation", {VoiceTaskKind::SpeechToSpeech, RunMode::Streaming}});
return result;
}
TaskResult run(IRecipeModelExecutor & executor, const TaskRequest & request,
const RecipeEventSink & sink) const override {
if (conversation_ ? !request.audio_input : (!request.text_input || request.text_input->text.empty()))
throw InvalidRequestError(id() + (conversation_ ? " requires audio" : " requires text"));
const int rate = parse_int_option(request.options, {"avatar_sample_rate"}).value_or(16000);
if (rate <= 0) throw InvalidRequestError("avatar_sample_rate must be positive");
const float speed = parse_finite_float_option(request.options, {"speaking_speed"}).value_or(1.0F);
if (speed < 0.5F || speed > 2.0F) throw InvalidRequestError("speaking_speed must be in [0.5, 2]");
const auto started = std::chrono::steady_clock::now();
AudioPipe pipe(rate);
TextPipe texts;
std::mutex output_mutex;
std::atomic<bool> tts_done{false}, conversation_done{!conversation_};
bool first_video = true, first_audio = true;
const auto publish = [&](const StreamEvent & event) {
std::lock_guard<std::mutex> lock(output_mutex);
if (sink) sink(event);
};
TaskResult reply;
std::thread conversation;
if (conversation_) conversation = std::thread([&] {
try {
TaskRequest question;
question.audio_input = request.audio_input;
question.text_input = request.text_input;
question.input_artifacts = request.input_artifacts;
question.options = options_for_recipe_role(request.options, "conversation", {"conversation", "tts", "avatar"}, false);
std::string pending, streamed;
reply = executor.stream("conversation", {VoiceTaskKind::SpeechToSpeech, RunMode::Streaming}, question,
[&](const StreamEvent & event) {
if (!event.partial_text || event.partial_text->text.empty()) return;
StreamEvent text;
text.partial_text = event.partial_text;
publish(text);
if (streamed.empty()) engine::debug::timing_log_scalar("recipe.conversation.first_text_ms", engine::debug::elapsed_ms(started));
streamed += text.partial_text->text;
pending += text.partial_text->text;
// Keep the unfinished tail. The framework splitter respects words, tags and UTF-8.
const auto chunks = engine::text::split_text_chunks(pending, 80, engine::text::TextChunkMode::TagAware);
size_t consumed = 0;
for (size_t i = 0; i + 1 < chunks.size(); ++i) {
consumed = pending.find(chunks[i], consumed) + chunks[i].size();
texts.push(chunks[i]);
}
pending.erase(0, consumed);
});
if (!reply.text_output || reply.text_output->text != streamed)
throw std::runtime_error("conversation streamed text differs from its final reply");
conversation_done = true;
engine::debug::timing_log_scalar("recipe.conversation.wall_ms", engine::debug::elapsed_ms(started));
for (auto & chunk : engine::text::split_text_chunks(pending, 80, engine::text::TextChunkMode::TagAware))
if (!chunk.empty()) texts.push(std::move(chunk));
texts.finish();
} catch (...) { texts.finish(std::current_exception()); }
});
TaskRequest synthesis;
synthesis.voice = request.voice;
synthesis.options = options_for_recipe_role(request.options, "tts", {"conversation", "tts", "avatar"}, true);
synthesis.options.erase("avatar_sample_rate");
synthesis.options.erase("speaking_speed");
TaskResult speech;
std::exception_ptr producer_error;
std::thread producer([&] {
try {
int source_rate = 0;
std::unique_ptr<engine::audio::StreamingSoxrResampler> resampler;
std::unique_ptr<minitts::app::StreamingSpeechTempo> tempo;
const auto emit_audio = [&](AudioBuffer audio) {
if (audio.samples.empty()) return;
if (first_audio) {
first_audio = false;
engine::debug::timing_log_scalar("recipe.tts.first_audio_ms", engine::debug::elapsed_ms(started));
engine::debug::trace_log_scalar("recipe.tts.first_audio_before_conversation_end", !conversation_done.load());
}
if (tempo) {
if (!speech.audio_output) speech.audio_output = AudioBuffer{source_rate, 1, {}};
append_audio_buffer(*speech.audio_output, audio);
}
StreamEvent pcm;
pcm.audio_output = std::move(audio);
publish(pcm);
pipe.push(resampler->process(engine::audio::mixdown_interleaved_to_mono_average(
pcm.audio_output->samples, pcm.audio_output->channels)));
};
const auto synthesize = [&](const Transcript & text) {
synthesis.text_input = text;
const auto part = executor.stream("tts", {VoiceTaskKind::Tts, RunMode::Streaming}, synthesis,
[&](const StreamEvent & event) {
if (!event.audio_output || event.audio_output->samples.empty()) return;
const auto & audio = *event.audio_output;
if (!resampler) {
source_rate = audio.sample_rate;
resampler = std::make_unique<engine::audio::StreamingSoxrResampler>(source_rate, rate);
if (speed != 1.0F) tempo = std::make_unique<minitts::app::StreamingSpeechTempo>(source_rate, speed);
}
if (source_rate != audio.sample_rate) throw std::runtime_error("TTS changed sample rate within a stream");
if (tempo) emit_audio(AudioBuffer{source_rate, 1, tempo->process(
engine::audio::mixdown_interleaved_to_mono_average(audio.samples, audio.channels))});
else emit_audio(audio);
});
if (!part.audio_output) throw std::runtime_error("TTS returned no audio");
if (!tempo) {
if (!speech.audio_output) speech.audio_output = AudioBuffer{part.audio_output->sample_rate, part.audio_output->channels, {}};
append_audio_buffer(*speech.audio_output, *part.audio_output);
}
};
if (conversation_) {
std::string text;
while (texts.read(text)) synthesize(Transcript{std::move(text), {}});
} else synthesize(*request.text_input);
if (!resampler) throw std::runtime_error("TTS returned no streamed audio");
if (tempo) emit_audio(AudioBuffer{source_rate, 1, tempo->process({}, true)});
tts_done = true;
engine::debug::timing_log_scalar("recipe.tts.wall_ms", engine::debug::elapsed_ms(started));
pipe.push(resampler->process({}, true));
pipe.finish();
} catch (...) {
producer_error = std::current_exception(); texts.cancel(); pipe.finish(producer_error);
}
});
TaskResult video;
try {
bool exhausted = false;
AudioChunkStream source{{rate, 1}, [&](int64_t maximum, std::vector<float> & samples) {
const bool more = pipe.read(maximum, samples);
exhausted = !more;
return more;
}};
TaskRequest animation;
animation.audio_input = AudioBuffer{rate, 1, {}};
animation.options = options_for_recipe_role(request.options, "avatar", {"conversation", "tts", "avatar"}, false);
video = executor.stream("avatar", {VoiceTaskKind::AudioGeneration, RunMode::Streaming}, animation,
[&](const StreamEvent & event) {
if (first_video && !event.output_artifacts.empty()) {
first_video = false;
engine::debug::timing_log_scalar("recipe.avatar.first_video_ms", engine::debug::elapsed_ms(started));
engine::debug::trace_log_scalar("recipe.avatar.first_video_before_tts_end", !tts_done.load());
engine::debug::trace_log_scalar("recipe.avatar.first_video_before_conversation_end", !conversation_done.load());
}
publish(event);
}, &source);
if (!exhausted) throw std::runtime_error("avatar did not consume the complete audio stream");
} catch (...) {
pipe.cancel(); texts.cancel(); producer.join();
if (conversation.joinable()) conversation.join();
throw;
}
producer.join();
if (conversation.joinable()) conversation.join();
if (producer_error) std::rethrow_exception(producer_error);
video.audio_output = std::move(speech.audio_output);
video.text_output = conversation_ ? std::move(reply.text_output) : request.text_input;
video.output_artifacts.insert(video.output_artifacts.end(),
std::make_move_iterator(reply.output_artifacts.begin()), std::make_move_iterator(reply.output_artifacts.end()));
engine::debug::timing_log_scalar("session.wall_ms", engine::debug::elapsed_ms(started));
return video;
}
private:
bool conversation_;
};
const RecipePluginRegistration registration([] { return std::make_unique<SpeakingAvatarRecipe>(); });
const RecipePluginRegistration conversation_registration([] { return std::make_unique<SpeakingAvatarRecipe>(true); });
} // namespace
} // namespace minitts::app

94
app/recipe/recipe.cpp Normal file
View file

@ -0,0 +1,94 @@
#include "recipe.h"
#include <algorithm>
#include <stdexcept>
#include <utility>
namespace minitts::app {
namespace {
std::vector<RecipeFactory> & recipe_factories() {
static std::vector<RecipeFactory> factories;
return factories;
}
} // namespace
RecipePluginRegistration::RecipePluginRegistration(RecipeFactory factory) {
if (!factory) {
throw std::runtime_error("cannot register an empty recipe factory");
}
recipe_factories().push_back(std::move(factory));
}
std::unordered_map<std::string, std::string> options_for_recipe_role(
const std::unordered_map<std::string, std::string> & options,
std::string_view role,
std::initializer_list<std::string_view> recipe_roles,
bool include_shared) {
std::unordered_map<std::string, std::string> selected;
const std::string prefix = std::string(role) + ".";
for (const auto & [key, value] : options) {
if (key.rfind(prefix, 0) == 0) {
continue;
}
bool belongs_to_another_role = false;
for (const auto candidate : recipe_roles) {
const std::string candidate_prefix = std::string(candidate) + ".";
if (key.rfind(candidate_prefix, 0) == 0) {
belongs_to_another_role = true;
break;
}
}
if (include_shared && !belongs_to_another_role) {
selected.emplace(key, value);
}
}
for (const auto & [key, value] : options) {
if (key.rfind(prefix, 0) == 0) {
selected.insert_or_assign(key.substr(prefix.size()), value);
}
}
return selected;
}
void RecipeRegistry::add(std::unique_ptr<IRecipe> recipe) {
if (!recipe) {
throw std::runtime_error("cannot register a null recipe");
}
const auto id = recipe->id();
if (id.empty()) {
throw std::runtime_error("recipe id must not be empty");
}
if (!recipes_.emplace(id, std::move(recipe)).second) {
throw std::runtime_error("duplicate recipe id: " + id);
}
}
const IRecipe & RecipeRegistry::require(std::string_view id) const {
const auto found = recipes_.find(std::string(id));
if (found == recipes_.end()) {
throw std::runtime_error("unknown recipe: " + std::string(id));
}
return *found->second;
}
std::vector<std::string> RecipeRegistry::ids() const {
std::vector<std::string> ids;
ids.reserve(recipes_.size());
for (const auto & [id, _] : recipes_) {
ids.push_back(id);
}
std::sort(ids.begin(), ids.end());
return ids;
}
RecipeRegistry make_recipe_registry() {
RecipeRegistry registry;
for (const auto & factory : recipe_factories()) {
registry.add(factory());
}
return registry;
}
} // namespace minitts::app

76
app/recipe/recipe.h Normal file
View file

@ -0,0 +1,76 @@
#pragma once
#include "engine/framework/runtime/session.h"
#include "../streaming/streaming.h"
#include <stdexcept>
#include <functional>
#include <initializer_list>
#include <memory>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace minitts::app {
struct RecipeModelRole {
std::string name;
engine::runtime::TaskSpec task;
};
using RecipeEventSink = std::function<void(const engine::runtime::StreamEvent &)>;
class IRecipeModelExecutor {
public:
virtual ~IRecipeModelExecutor() = default;
virtual engine::runtime::TaskResult run(
std::string_view role,
const engine::runtime::TaskSpec & task,
const engine::runtime::TaskRequest & request) = 0;
virtual engine::runtime::TaskResult stream(
std::string_view, const engine::runtime::TaskSpec &,
const engine::runtime::TaskRequest &, const RecipeEventSink &,
const AudioChunkStream * = nullptr) {
throw std::runtime_error("this recipe executor does not support streaming");
}
};
class IRecipe {
public:
virtual ~IRecipe() = default;
virtual std::string id() const = 0;
virtual std::string description() const = 0;
virtual std::vector<RecipeModelRole> roles() const = 0;
virtual engine::runtime::TaskResult run(
IRecipeModelExecutor & executor,
const engine::runtime::TaskRequest & request,
const RecipeEventSink & event_sink = {}) const = 0;
};
using RecipeFactory = std::function<std::unique_ptr<IRecipe>()>;
class RecipePluginRegistration {
public:
explicit RecipePluginRegistration(RecipeFactory factory);
};
std::unordered_map<std::string, std::string> options_for_recipe_role(
const std::unordered_map<std::string, std::string> & options,
std::string_view role,
std::initializer_list<std::string_view> recipe_roles,
bool include_shared);
class RecipeRegistry {
public:
void add(std::unique_ptr<IRecipe> recipe);
const IRecipe & require(std::string_view id) const;
std::vector<std::string> ids() const;
private:
std::unordered_map<std::string, std::unique_ptr<IRecipe>> recipes_;
};
RecipeRegistry make_recipe_registry();
} // namespace minitts::app

View file

@ -0,0 +1,78 @@
#include "streaming_tempo.h"
#include "engine/framework/io/dynamic_library.h"
#include <cmath>
#include <limits>
#include <stdexcept>
namespace minitts::app {
class StreamingSpeechTempo::Impl {
public:
using Create = void * (*)(int, int);
using Destroy = void (*)(void *);
using SetSpeed = void (*)(void *, float);
using Write = int (*)(void *, const float *, int);
using Read = int (*)(void *, float *, int);
using Flush = int (*)(void *);
using Available = int (*)(void *);
Impl(int sample_rate, float speed) {
if (sample_rate <= 0 || !std::isfinite(speed) || speed < 0.5F || speed > 2.0F)
throw std::invalid_argument("speech tempo requires a positive sample rate and speed in [0.5, 2]");
if (speed == 1.0F) return;
library = engine::io::open_dynamic_library({"libsonic.so.0", "libsonic.so", "libsonic.dylib", "sonic.dll", "libsonic.dll"});
if (!library) throw std::runtime_error("speaking_speed other than 1 requires libsonic");
const auto create = reinterpret_cast<Create>(engine::io::dynamic_library_symbol(library, "sonicCreateStream"));
destroy = reinterpret_cast<Destroy>(engine::io::dynamic_library_symbol(library, "sonicDestroyStream"));
const auto set_speed = reinterpret_cast<SetSpeed>(engine::io::dynamic_library_symbol(library, "sonicSetSpeed"));
write = reinterpret_cast<Write>(engine::io::dynamic_library_symbol(library, "sonicWriteFloatToStream"));
read = reinterpret_cast<Read>(engine::io::dynamic_library_symbol(library, "sonicReadFloatFromStream"));
flush = reinterpret_cast<Flush>(engine::io::dynamic_library_symbol(library, "sonicFlushStream"));
available = reinterpret_cast<Available>(engine::io::dynamic_library_symbol(library, "sonicSamplesAvailable"));
if (!create || !destroy || !set_speed || !write || !read || !flush || !available) {
engine::io::close_dynamic_library(library);
throw std::runtime_error("libsonic is missing streaming entry points");
}
stream = create(sample_rate, 1);
if (!stream) {
engine::io::close_dynamic_library(library);
throw std::runtime_error("sonicCreateStream failed");
}
set_speed(stream, speed);
}
~Impl() {
if (stream) destroy(stream);
engine::io::close_dynamic_library(library);
}
engine::io::DynamicLibraryHandle library = nullptr;
void * stream = nullptr;
Destroy destroy = nullptr;
Write write = nullptr;
Read read = nullptr;
Flush flush = nullptr;
Available available = nullptr;
bool finished = false;
};
StreamingSpeechTempo::StreamingSpeechTempo(int sample_rate, float speed) : impl_(std::make_unique<Impl>(sample_rate, speed)) {}
StreamingSpeechTempo::~StreamingSpeechTempo() = default;
std::vector<float> StreamingSpeechTempo::process(const std::vector<float> & samples, bool final) {
if (impl_->finished) throw std::runtime_error("speech tempo stream is already finalized");
if (!impl_->stream) { impl_->finished = final; return samples; }
if (samples.size() > static_cast<size_t>(std::numeric_limits<int>::max()))
throw std::invalid_argument("speech tempo input chunk exceeds libsonic's sample-count limit");
if (!samples.empty() && !impl_->write(impl_->stream, samples.data(), static_cast<int>(samples.size())))
throw std::runtime_error("sonicWriteFloatToStream failed");
if (final && !impl_->flush(impl_->stream)) throw std::runtime_error("sonicFlushStream failed");
const int count = impl_->available(impl_->stream);
if (count < 0) throw std::runtime_error("sonicSamplesAvailable returned an invalid count");
std::vector<float> output(static_cast<size_t>(count));
if (count && impl_->read(impl_->stream, output.data(), count) != count)
throw std::runtime_error("sonicReadFloatFromStream returned an incomplete block");
impl_->finished = final;
return output;
}
} // namespace minitts::app

View file

@ -0,0 +1,20 @@
#pragma once
#include <memory>
#include <vector>
namespace minitts::app {
// Pitch-preserving mono speech tempo. State spans chunks; flush once at stream end.
// Speed 1 is an exact passthrough and does not load libsonic.
class StreamingSpeechTempo {
public:
StreamingSpeechTempo(int sample_rate, float speed);
~StreamingSpeechTempo();
std::vector<float> process(const std::vector<float> & samples, bool final = false);
private:
class Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace minitts::app

936
app/server/README.md Normal file
View file

@ -0,0 +1,936 @@
# audio.cpp Server
`audiocpp_server` exposes audio.cpp models through HTTP and an optional WebUI.
The default runtime reuses each loaded model and its configured task session
across requests. You can register several models in one config; this does not
require the experimental parallel runtime.
## Contents
- [Quick start: full WebUI](#full-webui-no-config-file)
- [Quick start: first API request](#configured-api-first-request)
- [Build and choose a server mode](#build)
- [Configure models](#config)
- [Map CLI options to server config](#cli-options-to-server-configuration)
- [Control model loading and memory](#loading-and-memory)
- [Configure offline and streaming sessions](#offline-and-streaming-sessions)
- [Start and inspect the server](#start)
- [Voice presets and reference recordings](#voices)
- [API endpoints](#endpoints)
- [Troubleshooting](#troubleshooting)
- [Optional frontend modules and listeners](#optional-frontend-modules-and-listeners)
- [Experimental features](#experimental-features)
## Quick start
Choose **one** path below. Use an existing server binary, or [build one](#build).
Commands use `build/debug/bin/audiocpp_server` from a source checkout. For a
downloaded release, substitute its executable path, such as
`./audiocpp_server` on Linux/macOS or `.\audiocpp_server.exe` in PowerShell.
Run source-checkout commands from the repository root.
### Full WebUI (no config file)
To download models and run them in your browser:
```bash
build/debug/bin/audiocpp_server --ui --ui-management --backend cuda --log --log-file server.log
```
Open `http://127.0.0.1:8080`, install a model package from the model library,
then select it in the matching studio tab. Replace `cuda` with a backend your
binary and selected model support, such as `cpu`, `vulkan`, or `metal`.
Use **Lib → Speakers → New speaker preset** to save a model, voice, and
generation settings together, then select that speaker in **Apps → Text to
speech**. **Lib → Voices** manages reference recordings and their transcripts.
These libraries are stored in this browser, not in the server config. Existing
projects keep their own speaker settings when a library preset is edited or deleted.
The **Apps** tab, after Studio, is an early release. Workflows and saved project
formats may change; export audio you want to keep. It contains a project-based
**Text to speech** app:
- Import TXT/Markdown and preview text splitting before applying it.
- In **Speakers**, create or reuse named speakers and assign them to passages.
Set a project default or apply a speaker to all passages. Search and filter
passages; the browser remembers the last selected passage for each project.
- Edit each chosen take with **Edit audio**: zoom/pan its waveform,
place a playhead, insert silence, drag-select and delete existing gaps or
audio ranges, remove inserted regions, and undo/redo.
Edits stay with that take and persist with the project; original recordings
are retained. Boundary previews and exports use the edited audio.
- Generate chunks individually or in a batch, compare takes, and choose the audio
to keep. Optional reference transcription uses a separately selected ASR model.
- In **Arrange**, inspect joins with actual waveforms and boundary playback.
Adjust pauses with + / − or enter an exact duration; choose **Use default**
to reset a join. Custom pauses are saved with the project.
- Combine the selected takes, then use **Export** for optional model enhancement,
gain or peak normalization, and WAV PCM16 or WebM Opus download.
Projects, references and generated takes persist in this browser. Regeneration
preserves chosen takes; splitting/merging archives the original chunks. Changing
text/settings marks affected audio out of date instead of silently reusing it.
An app applies the saved load/session options when UI management owns the model.
For a server-configured model, its configuration must already match the saved settings;
apps do not rewrite the server config. App modules are maintained independently;
see the [app contribution guide](../../webui/native/src/lib/apps/README.md).
This requires a binary built with `AUDIOCPP_BUILD_NATIVE_MODEL_MANAGER=ON`.
If the server reports that UI management is unavailable, use the configured API
path below or [rebuild with that option](#choose-a-server-mode). The plain `--ui`
flag does not enable downloads or register models by itself.
### Configured API (first request)
**1. Get a model.** Download the complete
[Kokoro package and its dependencies](../../docs/models/kokoro_tts.md).
This example uses its built-in `af_heart` voice: no reference WAV or `voice_dir`
is needed. It uses CPU inference and works with a CPU-only server build.
**2. Create the config.** Save this as `server.json`, replacing the GGUF path
with your actual file. A config does not download model weights.
```json
{
"host": "127.0.0.1",
"port": 8080,
"backend": "cpu",
"lazy_load": true,
"max_loaded_models": 1,
"models": [
{
"id": "kokoro",
"family": "kokoro_tts",
"path": "/absolute/path/Kokoro-82M-GGUF/kokoro-82m-q8_0.gguf",
"task": "tts",
"mode": "offline",
"default_voice_preset": { "voice_id": "af_heart" }
}
]
}
```
**3. Start the server.** Keep this terminal open:
```bash
build/debug/bin/audiocpp_server --config server.json --log --log-file server.log
```
**4. Send a request.** In another terminal:
```bash
curl --fail-with-body http://127.0.0.1:8080/health
curl --fail-with-body http://127.0.0.1:8080/v1/models
curl --fail-with-body http://127.0.0.1:8080/v1/audio/speech \
-H 'Content-Type: application/json' \
-d '{"model":"kokoro","input":"Welcome. This speech is generated locally with audio.cpp."}' \
-o speech.wav
```
Open `http://127.0.0.1:8080` for the configured-model WebUI. `/health` confirms
the server is running, not that a lazy model has loaded. The first generation
loads the model and is slower than later requests. Check curl's exit status and
`server.log` before playing the output; an unsuccessful request may save an
error body instead of a WAV.
The commands use a POSIX shell. On Windows, use `curl.exe` and put JSON in a
request file, for example `--data-binary @request.json`. Use forward slashes
or escaped backslashes in JSON paths, such as `C:/models/model.gguf`.
For other models, start with the [ready-to-edit configs and complete requests](../../examples/server/README.md).
The request's `"model": "kokoro"` matches the config's `id`, not its
`family: "kokoro_tts"` or GGUF filename. To use the GPU later, restart with
`--backend cuda` (or another supported backend); the config otherwise stays the same.
## Build
```bash
cmake -S . -B build/debug -DCMAKE_BUILD_TYPE=Debug -DENGINE_ENABLE_CUDA=ON
cmake --build build/debug --parallel 8 --target audiocpp_server
```
Enable the backend you plan to run: `ENGINE_ENABLE_CUDA=ON`,
`ENGINE_ENABLE_VULKAN=ON`, `ENGINE_ENABLE_HIP=ON`, or `ENGINE_ENABLE_METAL=ON`.
CPU support is included by default. Build flags decide which backends exist;
`--backend` selects one when starting the server. See the
[Linux](../../docs/build/linux.md), [Windows](../../docs/build/windows.md),
and [HIP](../../docs/build/HIP.md) build guides for dependencies.
Commands here use `build/debug/bin/`. For a downloaded release, substitute the
path to its `audiocpp_server` executable; it does not need rebuilding.
### Choose a server mode
Pick the mode that matches the behavior you want:
| If you want... | Build with... | Run with... | Behavior |
|---|---|---|---|
| API/config-driven server, no UI | default build | `audiocpp_server --config server.json --no-ui` | Uses models declared in the config; does not serve the UI. |
| UI for configured models | default build | `audiocpp_server --config server.json --ui` | Browser UI can run configured models, without downloads, deletes, or dynamic package management. |
| Full UI with downloads and model switching | `-DAUDIOCPP_BUILD_NATIVE_MODEL_MANAGER=ON` | `audiocpp_server --ui --ui-management --backend <backend>` | UI can browse packages, download models, load/unload models, delete packages, and use temporary uploads. Models from a `--config` stay available as configured ([details](#configured-models-with-ui-management)). |
| Standalone deployed binary without local `model_specs/` | `-DAUDIOCPP_DEPLOYMENT_BUILD=ON` | `audiocpp_server --config server.json` | Binary carries compiled package specs for fallback model-spec lookup. |
Native model management uses bundled BoringSSL by default. Normal server builds
do not build or link that HTTP/TLS dependency. These build choices can be
combined: deployment mode supplies specs, while native management supplies the
download/install functionality. Neither enables experimental parallel execution.
Optional frontend modules are described [later](#optional-frontend-modules-and-listeners).
For native-manager builds, these TLS alternatives do not change the API:
| Need | Additional CMake setting |
|---|---|
| Use a local BoringSSL archive instead of downloading it during configure | `-DAUDIOCPP_BORINGSSL_ARCHIVE=/path/to/boringssl.tar.gz` |
| Use system OpenSSL instead of bundled BoringSSL | `-DAUDIOCPP_USE_SYSTEM_OPENSSL=ON` |
Both require `-DAUDIOCPP_BUILD_NATIVE_MODEL_MANAGER=ON`. They do not enable an
HTTPS listener; that is a separate frontend extension.
## Config
For ready-to-edit configs with explained paths, voice-library setup, and complete
requests, see [server examples](../../examples/server/README.md). They cover
Qwen3-TTS, BreezeTTS, Kokoro, PocketTTS, YuE2, MiniMax Music3, and Parakeet ASR.
The following example registers TTS and ASR in one process. Replace both paths;
keep PocketTTS's downloaded `embeddings/` directory beside its GGUF. Use only
entries for packages you installed. The defaults for optional server settings
are listed in [loading and memory](#loading-and-memory),
[logging](#logging-and-request-limits), and [busy timeouts](#busy-requests-and-timeouts).
```bash
cat > server.json <<'JSON'
{
"host": "127.0.0.1",
"port": 8080,
"backend": "cuda",
"device": 0,
"threads": 4,
"lazy_load": true,
"max_loaded_models": 1,
"log_request_body": false,
"max_request_body_bytes": 2147483648,
"models": [
{
"id": "pocket-tts",
"family": "pocket_tts",
"path": "/absolute/path/PocketTTS-GGUF/english/pocket-tts-english-q8_0.gguf",
"task": "tts",
"mode": "offline",
"load_options": {
"language": "english"
},
"default_voice_preset": {
"voice_id": "alba"
},
"voice_presets": {
"cosette": {
"voice_id": "cosette"
}
}
},
{
"id": "qwen3-asr",
"family": "qwen3_asr",
"path": "/absolute/path/Qwen3-ASR-0.6B-GGUF/qwen3-asr-0.6b-f16.gguf",
"task": "asr",
"mode": "offline"
}
]
}
JSON
```
### Model entries
| Field | Meaning |
|---|---|
| `id` | Your server-side name. Send this exact string as the request's `model`, not the family or GGUF filename. |
| `family` | The implementation to load, such as `pocket_tts` or `qwen3_asr`. Use the model's documented family. |
| `path` | An existing GGUF file or the complete package directory required by that family. Multi-component models may need additional package files. |
| `task` | The model's documented route, such as `tts`, `clon`, `vdes`, `asr`, or `align`. Capabilities such as voice cloning do not imply an identically named route. |
| `mode` | `offline` or `streaming`, supported by that model. It selects the session created by the server. |
| `device` | Optional backend device index for this model. When omitted, the top-level `device` is used. |
| `load_options` | Options consumed while loading weights/assets. |
| `session_options` | Options consumed when creating the reusable session. |
| `default_request_options` | Defaults for the model's inference requests; request values with the same key override them. |
Always set `family`, `task`, and `mode` explicitly rather than relying on inferred
names or defaults. Several entries can use the same family with different GGUFs,
tasks, or modes, provided each entry has a unique `id`.
For instruction-conditioned models, per-model `"instruct": "..."` is a shortcut
for `"default_request_options": {"instruct": "..."}`. Use one form or the other;
setting the shortcut together with `instruct` or `instruction` in
`default_request_options` is rejected as ambiguous.
### Per-model devices
Servers with more than one accelerator can keep different models resident on
different devices. Set the top-level `device` as the default and override it on
individual model entries:
```json
{
"backend": "cuda",
"device": 0,
"max_loaded_models": 0,
"models": [
{"id":"speech","family":"kokoro_tts","path":"models/kokoro.gguf","device":0},
{"id":"music","family":"stable_audio","path":"models/stable-audio","device":1}
]
}
```
The indices use audio.cpp's backend device order; run the server with
`--list-devices` to inspect it, or `--list-devices --json` for PCI ids and
free/total memory in [JSON](../../docs/usage.md#listing-devices). Each model remains wholly on one device: this
setting assigns models independently and does not split one model across GPUs.
`GET /v1/models` reports the effective `device` for every entry. Dynamic loads
may also send `device`; when a registered model is reloaded and the field is
omitted, its existing assignment is retained. Set `max_loaded_models` high
enough for the number of models that should remain resident, or use `0` for no
global residency limit.
### Default session options
Top-level `"default_session_options"` sets session options for every model of a
family, keyed `<family>.<option>`. A model gets the entries of its family that its
own `session_options` do not set. This applies to the configured models and to
models loaded later through `POST /v1/models/load`, which is how the WebUI loads
a model with `--ui-management`. The WebUI sends the catalog's session options,
not the config's, so without defaults such a load drops settings like memory
options:
```json
{
"ui_management": true,
"default_session_options": {
"irodori_tts.mem_saver": true,
"irodori_tts.codec_decode_chunk_steps": 100
},
"models": []
}
```
A key without a family prefix is rejected when the config loads. A value for a
family that never loads is ignored.
### Configured models with UI management
With `--ui-management` and a `--config`, the WebUI offers the catalog and the
config's models. A config model whose `id` the catalog does not have is listed
as its own entry and used as configured, as without UI management: it loads on
its first request, and the WebUI releases its memory with
[`POST /v1/tasks/unload_models`](api.md#post-v1tasksunload_models) instead of
reloading it from the catalog. A config model under a catalog `id` stays that
catalog entry, so the WebUI can load another format of it.
A WebUI load of an `id` the config defines carries only the path, the family,
the task, the mode and the load and session options. When its family and task
match the config's, the model keeps the config's `default_request_options`,
`voice_presets`, `default_voice_preset`, `busy_timeout_ms` and `live_ingest`;
a load of another family or task under that `id` gets none of them.
### Loading an installed package by id
A server built with `AUDIOCPP_BUILD_NATIVE_MODEL_MANAGER=ON` also lets
`POST /v1/models/load` name an installed package with `package_id` instead of
`path`, so a client does not need to know where the models root is:
```bash
curl -X POST http://127.0.0.1:8080/v1/models/load \
-H 'Content-Type: application/json' \
-d '{"id":"sep","package_id":"htdemucs_q8_0","task":"sep"}'
```
The server reads the package's metadata from the package manager and asks the
family's loader, through `resolve_package_load`, for the path, family, load
options and session options, the same target the WebUI loads. The request then
goes through the existing load handler as if it had sent them. `family`
defaults to the package's family; a different `family` is rejected. A
`load_options` or `session_options` key the request sends wins over the
resolved one. Each precision is its own package id, such as `kokoro_82m_q8_0`
and `kokoro_82m_bf16`. Sending both `path` and `package_id` is rejected with
400, and an unknown or not-installed package returns 404. Requests with
`path`, and servers built without the native model manager, are unchanged.
### Paths and model specs
Relative model and configured voice-library paths resolve against the config
file's directory. Use absolute paths in your first config. Request-time audio
paths refer to files accessible on the **server's computer**, not the client's;
upload audio when the server cannot access the original file. Option-map path
handling is model-specific; do not assume every option is config-relative.
Package/resource loading prefers the spec embedded in a GGUF. For option validation,
capabilities, and help, contracts instead prefer `model_specs/<family>.json` in the
working directory or its parents, then the compiled catalog
(`AUDIOCPP_DEPLOYMENT_BUILD=ON`), then the embedded spec. External specs must be
compatible with the installed runtime. `model_spec_override` takes priority over
both lookup orders. It accepts either one JSON file or a directory containing `<family>.json`.
Set it at the top level to provide a server-wide override, or inside one model entry;
the per-model value takes precedence. The equivalent command-line option is
`--model-spec-override <json-or-directory|embedded>`. Set
`"model_spec_override": "embedded"` to require the selected GGUF's schema-v1
embedded contract, ignoring external and compiled contracts. Missing or legacy
embedded contracts fail without fallback. With `--log`, `runtime.model.contract_spec`
shows the selected contract. Use `./embedded` for a literal file/directory of that name.
### Backends
Set top-level `"backend"` to `"cuda"`, `"cpu"`, `"hip"` (`"rocm"` is an alias),
`"vulkan"`, or `"metal"`. The backend must be included in the binary and supported
by the selected model. CUDA is the primary optimization target; performance and
model coverage differ on other backends. `--backend` overrides the config for
the process, not for an individual HTTP request. Use separate server processes
on different ports if clients need different backends simultaneously.
### CLI options to server configuration
Inference CLI flags are not arbitrary server-config fields. Put each model
option in its documented scope; request options go in `options` on the speech
endpoint, or `request.options` on generic task endpoints.
| Inference CLI | Server config | Per-request override |
|---|---|---|
| `--family qwen3_tts` | `models[].family` | Select the entry's `id` with `model`; do not send `family`. |
| `--model /path/model.gguf` | `models[].path` | Choose a different configured `model` id. |
| `--task tts --mode offline` | `models[].task` and `models[].mode` | Choose the matching entry; a request does not change its session mode. |
| `--load-option key=value` | `models[].load_options` | Not an inference-request option. |
| `--session-option family.key=value` | `models[].session_options` | Not an inference-request option. |
| `--session-option family.key=value` for every model of that family | top-level [`default_session_options`](#default-session-options) | Not an inference-request option. |
| `--request-option key=value` | `models[].default_request_options` | Speech `options`, or generic `request.options`. |
| `--seed 1234`, `--max-tokens 512` | `default_request_options`: `seed`, `max_tokens` | Speech also accepts these as top-level fields. |
| `--instruct "..."` | `default_request_options`: use the model's documented option, usually `instruct` | `options.instruct` for a model accepting `instruct`; see the speech endpoint for the distinct `instructions` field. |
| `--voice-ref` and `--reference-text` | `default_voice_preset` / `voice_presets` | Speech `voice_ref` and `reference_text`; see [voices](#voices). |
For instruction-conditioned speech requests, the normalized top-level `instruct`
field maps to the model option `instruct`, matching CLI `--instruct`. The existing
`instructions` field remains supported and maps to `instruction` for backward
compatibility. Each top-level field overrides its corresponding option under
`options`. An explicit request instruction overrides either configured spelling,
preserving the request spelling for model compatibility. Do not send both
top-level spellings in one request; the server rejects that ambiguity.
Keep family prefixes on session/load option names where the model's docs require
them. The server does not automatically prefix arbitrary keys or translate every
CLI flag. A key accepted by one family may be rejected or unused by another.
Use [model-specific docs](../../docs/usage.md#model-docs) and
[`GET /v1/models?include_params=true`](api.md#get-v1models) for declared request
controls. That endpoint does not list load/session options.
For example, this is the options portion of a BreezeTTS model entry, not a full
server config:
```json
{
"session_options": {
"breeze_tts.reference_cache_slots": 1
},
"default_request_options": {
"seed": 1234,
"instruct": "A calm narrator speaking clearly.",
"text_chunk_size": 600
}
}
```
Send `"options": {"instruct": "A cheerful narrator."}` on a speech request to
override the matching default. Use the same option spelling in defaults and
requests: the server merges by key, not by model-specific alias equivalence.
Load/session options are not changed by that request.
### Loading and memory
These are server-level settings. The examples enable lazy loading and allow one
resident model; the defaults below apply when keys are omitted from a config
file. Starting the full UI without a config enables lazy loading automatically.
| Config key | Default | What it controls | Startup override |
|---|---:|---|---|
| `lazy_load` | `false` | Defer model loading/session creation until the first request. Per-model `lazy` overrides it. | Set in config. |
| `max_loaded_models` | `0` | Maximum resident models; `0` disables the limit, `1` keeps one model resident. | `--max-loaded-models <n>` |
| `idle_unload_ms` | `0` | Unload resident models after the server has had no model load/run activity for this long; `0` disables it. | `--idle-unload-ms <ms>` |
| `min_free_memory_mb` | `0` | Free-memory headroom in MiB required after an estimated model load; `0` disables the guard. | `--min-free-memory-mb <mb>` |
| `resample_kernel_cache_mb` | `32` | MiB of resampling kernels kept for reuse across requests, evicting the least recently used first; `0` disables the cache. A kernel larger than the limit, such as the 84.5 MiB one for 44056 Hz to 16 kHz, is rebuilt on every request; raise the limit for such inputs. | `--resample-kernel-cache-mb <mb>` |
> [!WARNING]
> Lazy loading does not unload a model after each request. Used models stay
> resident until explicitly unloaded, evicted by a residency limit, unloaded
> after server inactivity, or the process exits.
When the residency limit is reached, loading another model first evicts the
least recently used **idle** model. Its next request reloads it. Models running
inference are not evicted; if every resident model is busy, the new load fails
with HTTP 503. If more non-lazy entries are configured than the limit permits,
startup loads the first entries up to the limit and defers the remainder.
Idle unloading frees memory during quiet periods, not while inference is active.
It complements the resident-model limit; it is not a per-request teardown mode.
The next request reloads the model.
The optional free-memory guard checks host memory and GPU memory where available.
Its load estimate covers the selected weight file/package plus session auxiliary
files, with a runtime overhead factor and fixed floor. A family that holds
compiled engines resident reports its selected resource paths and final residency
through its loader. The family resolves its execution plan and applies its own
eviction policy; the guard only adds the reported bytes and excludes those files
from the weight scan. RKNN engines use driver-reported residency where available,
otherwise file size, without the weight runtime factor. A directory normally
selects `model.gguf` or its sole GGUF; several GGUFs without `model.gguf` are
ambiguous to the estimator. Unsupported family-specific layouts may skip the
guard with a log message, while an invalid spec-driven package can still fail
in the loader. Safetensors/HF layouts use the full checkpoint tree. A failed
guard returns HTTP 503 with `insufficient_memory`. It does not predict the exact
peak workspace of every inference.
> [!NOTE]
> `max_loaded_models: 1` limits how many models are resident, not the peak memory
> needed by one model. `lazy_load` changes when loading happens; it does not make
> a model smaller. The first request after an unload pays the load cost again.
### Logging and request limits
Start with `--log --log-file server.log` when diagnosing failures. Request-body
logging is separate and off by default to avoid accidentally recording prompts,
paths, and model options.
| Setting | Default | Meaning |
|---|---:|---|
| Config `log_request_body` | `false` | Log JSON request bodies only when logging is also enabled with `--log` or `--log-file`. Audio payload bytes are not printed. |
| Config `max_request_body_bytes` | `2147483648` | Maximum buffered HTTP body in bytes (2 GiB), before routing. It covers JSON and audio uploads, including base64 expansion. |
| Flag `--load-progress 0\|1` | `0` | With logging enabled, emit model-load progress keys. This is not generation progress and can be inaccurate for multi-component packages. |
Multipart logging includes filenames and byte counts; raw/live audio logging
includes only route, format/query, and size/stream metadata. Choose a body limit
that fits your expected uploads and host RAM. Values above `2^53 - 1` are rejected
because the config JSON parser stores numbers as doubles. Live ingestion has
its own [incremental body limits](api.md#post-v1audiotranscriptionslive).
### Busy requests and timeouts
The default runtime runs one request per model at a time. Top-level
`busy_timeout_ms` limits waiting for a busy model; it does **not** cancel an
inference already running on the backend. Requests that cannot acquire the
model within the policy return HTTP 503 (`server_busy`), or an error event if
streaming headers have already been sent. The default is `300000` (5 minutes);
`0` disables the guard. Set it above the slowest legitimate inference for that
model. `--busy-timeout-ms <ms>` overrides the server-level config value.
The bound is resolved in three layers, since model runtimes differ by orders of magnitude (a short TTS clip versus minutes of music generation):
1. **Server:** top-level `"busy_timeout_ms"` (or `--busy-timeout-ms`) sets the default.
2. **Model:** `"busy_timeout_ms"` inside a model entry overrides the server default and is the ceiling for requests to that model.
3. **Request:** `"busy_timeout_ms"` in the request body, multipart form, or live-ingest query bounds that caller's wait.
A request may ask for a **shorter** bound than the model's ceiling but never a longer one — `effective = min(request, ceiling)` — so a client cannot weaken the guard and reintroduce the hang it prevents. Because `0` means "unbounded", it compares as infinity on both sides: a request asking for `0` is still capped by the model ceiling, while under a ceiling of `0` a request's own bound is honored.
```json
{
"busy_timeout_ms": 300000,
"models": [
{ "id": "tts", "busy_timeout_ms": 30000, "...": "..." },
{ "id": "music", "busy_timeout_ms": 900000, "...": "..." },
{ "id": "asr", "...": "..." }
]
}
```
Here `asr` inherits 300000, a request to `music` asking for `60000` waits 60 s, and one asking for `999999` is clamped to 900000.
### Offline and streaming sessions
For streaming endpoints, configure the model with `"mode": "streaming"` and
use that entry's id in the request. Setting `stream=true` on an offline entry
does not create a streaming session. Register separate ids if you need both
modes of one model. Each entry creates its own session and can consume memory.
See [streaming_example.json](streaming_example.json):
```json
{
"host": "127.0.0.1",
"port": 8080,
"backend": "cuda",
"device": 0,
"threads": 1,
"lazy_load": true,
"models": [
{
"id": "voxcpm2-stream",
"family": "voxcpm2",
"path": "/path/to/models/VoxCPM2",
"task": "tts",
"mode": "streaming"
},
{
"id": "nemotron-stream",
"family": "nemotron_asr",
"path": "/path/to/models/nemotron-3.5-asr-streaming-0.6b",
"task": "asr",
"mode": "streaming"
}
]
}
```
## Start
```bash
build/debug/bin/audiocpp_server --config server.json --log --log-file server.log
```
Command-line server settings override their configured values:
```bash
build/debug/bin/audiocpp_server --config server.json --backend vulkan
build/debug/bin/audiocpp_server --config server.json --voice-dir /absolute/path/to/voice
```
Use `--no-ui` for API-only operation. Full model-management UI requires the
[native-manager build](#choose-a-server-mode) and `--ui --ui-management`.
Run `audiocpp_server --help` for server flags; inference controls belong in the
model option maps, not as arbitrary server command-line flags.
Keep `host` at `127.0.0.1` for local access. `0.0.0.0` binds all interfaces;
clients connect using the server's actual address, not `0.0.0.0`. Do not expose
model-management and server-file access to an untrusted network.
## Voices
### Configured presets and defaults
For TTS models that need repeated voice-clone context, set a model-level `default_voice_preset` so OpenAI-compatible clients can omit `voice_ref` and `reference_text` on each request:
```json
{
"id": "omnivoice",
"family": "omnivoice",
"path": "/absolute/path/to/models/OmniVoice",
"task": "tts",
"mode": "offline",
"default_voice_preset": {
"voice_ref": "/absolute/path/to/reference.wav",
"reference_text": "Reference transcript for the reference audio."
}
}
```
For multiple server-side presets, use `voice_presets` and optionally point `default_voice_preset` at one of those preset names:
```json
{
"voice_presets": {
"assistant": {
"voice_ref": "/absolute/path/to/assistant.wav",
"reference_text": "Reference transcript for assistant."
},
"narrator": {
"voice_ref": "/absolute/path/to/narrator.wav",
"reference_text": "Reference transcript for narrator."
}
},
"default_voice_preset": "assistant"
}
```
Define `voice_presets` once and put every named preset inside that object. Duplicate JSON keys are rejected so a config cannot silently drop presets.
When a request sends `"voice": "assistant"`, the server uses that entry's
configured preset. If the name is not a preset, the server checks the shared
reference library before passing it through as a model-native voice ID.
For built-in speakers, configure `voice_id` using an ID valid for that model,
such as `af_heart` for Kokoro; preset names and native voice IDs are not universal.
A preset can also carry request `options` for the model. Explicit request options
override preset options with the same key. Different aliases are not merged;
supplying conflicting aliases may produce a model validation error. The model
validates preset options like any other request option. For example, an
Irodori-TTS speaker embedding selected by name:
```json
{
"voice_presets": {
"narrator": {
"options": {
"speaker_embedding_file": "/absolute/path/to/narrator.speaker.safetensors"
}
}
}
}
```
A request `{"input": "Hello.", "voice": "narrator"}` then uses that embedding.
Option values are not paths to the server, so a relative path is not resolved
against the config file the way `voice_ref` is; use absolute paths.
### Voice library (`voice_dir`)
Set server-level `"voice_dir"` to a folder of your reference WAV recordings and
a `prompt_text` mapping file. A request `"voice": "demo_01_man"` then selects
`voice_dir/demo_01_man.wav` for cloning, without sending `voice_ref` and
`reference_text` each time. This is a shared server-side reference library,
not a list of speaker presets embedded in model weights:
```json
{
"voice_dir": "/absolute/path/to/voice",
"models": [
{
"id": "qwen3-tts",
"family": "qwen3_tts",
"path": "/absolute/path/to/models/Qwen3-TTS",
"task": "tts",
"mode": "offline"
}
]
}
```
The directory holds one `.wav` per reference voice (`demo_01_man.wav`,
`demo_02_woman.wav`, ...) and a file named exactly `prompt_text` (no `.txt`
extension), with one `<basename-without-extension>|<transcript>` line per voice:
```
demo_01_man|okay,I'm Cemo and what you just heard wasn't a human voice.
demo_02_woman|以前我对这句话一知半解,现在好像有点懂了。因为你我开始留意很多以前不曾关心的事,开始对这个世界有了更多的好奇和善意。
```
Relative `voice_dir` paths resolve against the config file's directory. The command-line option `--voice-dir <directory>` overrides the configured value, which is useful when a process manager launches multiple single-model server configurations against one shared voice library. Relative command-line paths resolve against the process working directory.
When a request sends `"voice"` that is not a configured model preset, the server checks `<voice_dir>/<name>.wav`; if the file exists it is loaded as the cloning reference, and the `<name>` transcript from `prompt_text` is injected as `reference_text` unless the request already provides one.
Server-side resolution of reference audio and voice names:
1. Explicit `voice_ref` replaces any preset reference audio and bypasses WAV-name lookup.
2. `voice` matching a configured model preset — preset wins.
3. `voice` matching a wav basename in `voice_dir` — voice-library clone.
4. Otherwise, `voice` is passed as the model-native cached voice ID.
If neither `voice` nor `voice_ref` is supplied, the configured default preset
applies. An empty string or `null` for `voice` is treated as omitted.
Prefer one selection method per request: combining `voice` and
`voice_ref` can pass both a native ID and audio to the model, whose interpretation
is model-specific. Top-level `reference_text` overrides the preset/library text.
[`GET /v1/audio/voices`](api.md#get-v1audiovoicesmodelid) lists library WAV
basenames, deduplicated against configured preset and cached voice names.
With native UI management enabled, or with the UI asked for (`--ui`, or
`"ui": true` in the config), and no configured `voice_dir`, the server provides
embedded demo recordings in a temporary reference library. A server whose UI is
only on by default does not get them. Those names are reference recordings, not
the selected model's built-in speaker IDs.
Use distinct library filenames and model preset names. The library is shared
across configured models; naming a reference WAV after a model-native speaker
can cause that name to select the recording instead. Only models that support
reference-audio conditioning can use these recordings for cloning.
### Built-in voices and discovery
Some models, including Kokoro and KittenTTS, store built-in voices in the GGUF.
`/v1/audio/voices` does not enumerate those embedded presets. Select them using
the IDs documented for that model, or configure named `voice_presets` pointing
to those IDs. An empty discovery list does not mean the built-in voices are
unavailable. `voice_dir` is not required to use them.
The WebUI's browser-saved voice library is separate from this server directory;
adding a recording in the browser does not itself create a named WAV in
`voice_dir`.
## Endpoints
Choose the endpoint by the result and transport you need. Not every model
supports every task, mode, or optional output.
| Need | Endpoint | Input / response |
|---|---|---|
| Check readiness / configured IDs | <a id="get-health"></a>[`GET /health`](api.md#get-health), <a id="get-v1models"></a>[`GET /v1/models`](api.md#get-v1models) | JSON metadata, including the build version and git commit; a registered model may still be unloaded. |
| Generate speech | <a id="post-v1audiospeech"></a>[`POST /v1/audio/speech`](api.md#post-v1audiospeech) | JSON text and voice controls; WAV by default, or streaming audio/SSE when supported. |
| Transcribe a recording | <a id="post-v1audiotranscriptions"></a>[`POST /v1/audio/transcriptions`](api.md#post-v1audiotranscriptions) | Server-local WAV path or file upload; text/timing, optionally transcript SSE. |
| Get timestamps or speaker labels | <a id="post-v1audiotranscriptionsdetails"></a>[`POST /v1/audio/transcriptions/details`](api.md#post-v1audiotranscriptionsdetails) | Same inputs; includes detail arrays the model actually produces. |
| Align a known transcript | <a id="post-v1audioalignments"></a>[`POST /v1/audio/alignments`](api.md#post-v1audioalignments) | Multipart WAV, text and model-required language; word alignment. |
| Transcribe live capture | <a id="post-v1audiotranscriptionslive"></a>[`POST /v1/audio/transcriptions/live`](api.md#post-v1audiotranscriptionslive) | Incremental raw PCM input and SSE output; native full-duplex HTTP client required. |
| List discoverable voice names | <a id="get-v1audiovoicesmodelid"></a>[`GET /v1/audio/voices`](api.md#get-v1audiovoicesmodelid) | Configured presets, cached IDs and reference-library names, not every embedded model preset. |
| Run another audio task | <a id="post-v1tasksrun"></a>[`POST /v1/tasks/run`](api.md#post-v1tasksrun) | Generic framework request/result, including separation and enhancement. |
| Stream a generic task | <a id="post-v1tasksstream"></a>[`POST /v1/tasks/stream`](api.md#post-v1tasksstream) | Generic events; use `stream_format: "sse"` for incremental delivery. |
| Native offline batching | <a id="post-v1batchestranscriptions"></a>[`POST /v1/batches/transcriptions`](api.md#post-v1batchestranscriptions), <a id="post-v1tasksbatch"></a>[`POST /v1/tasks/batch`](api.md#post-v1tasksbatch) | Multiple inputs, only for models implementing native batching. |
| Release model memory | <a id="post-v1tasksunload_models"></a>[`POST /v1/tasks/unload_models`](api.md#post-v1tasksunload_models), <a id="post-v1tasksunload_all_models"></a>[`POST /v1/tasks/unload_all_models`](api.md#post-v1tasksunload_all_models) | Unload; the next request reloads the model. |
For remote clients, upload the recording rather than sending the client's file
path. The core audio routes accept WAV; MP3/FLAC support requires the optional
[audio decoding frontend](#optional-frontend-modules-and-listeners). Multipart
fields, query parameters, and JSON fields are not interchangeable between routes.
## Troubleshooting
| Symptom | Check first |
|---|---|
| `/health` succeeds but generation fails | With lazy loading, readiness does not load weights. Check the first request's error and `server.log`, the package path, dependencies, and backend support. |
| Unknown model or wrong output task | Request `model` must match `models[].id`; check that entry's `task` and `mode` and select the matching endpoint. |
| Streaming requested but no incremental output | Use a streaming entry and the endpoint's streaming fields. Generic `/v1/tasks/stream` returns buffered JSON unless `stream_format` is `sse`. File-upload transcription cannot emit while the file is being uploaded. |
| Voice list is empty or shows unexpected names | Embedded GGUF speakers are not enumerated. `voice_dir` is shared; use configured presets or the model's documented native IDs and avoid filename collisions. |
| Unknown option or a default is not overridden | Check the exact model option name and its load/session/request scope. Defaults merge by key. Check the binary and GGUF spec versions rather than assuming every CLI flag is a top-level HTTP/config field. |
| HTTP 503 | Read the error: `server_busy` is a wait/admission issue; `insufficient_memory` is the optional load guard. A busy timeout does not abort the request already running. |
| Out of VRAM after switching models | Use `max_loaded_models: 1` or unload idle models. One model's inference still must fit by itself; a weight-size estimate is not a guarantee about graph workspace. |
| Client audio is not found or a file is rejected | A JSON path is server-local. Use multipart WAV upload for remote transcription; MP3/FLAC need the audio decoding frontend. A browser's WebM recording is not a WAV. |
| Browser reports CORS | Inspect the HTTP status/body as well as headers. Cross-origin access is disabled by default; see [experimental CORS](#experimental-cors). The same-origin embedded UI does not require it. |
### Timing and streaming clients
`rtf` is inference wall time divided by audio duration, not elapsed seconds or
time to first output. Speech uses generated audio duration; transcription uses
input recording duration. For offline requests, model loading and waiting for the
model are outside the timed inference; the first HTTP request can take longer
than the returned `wall_ms`. Use a warm request when comparing inference speed.
Streaming `ttft_ms` and live speech timings have endpoint-specific definitions
documented in their sections; they are not whole-request latency measurements.
An HTTP 200 status at the start of SSE does not prove the run succeeded. Read
through the endpoint's completion event and handle error events. Disable proxy
buffering for incremental responses; live ingestion additionally needs
full-duplex request transport. See [live deployment](api.md#post-v1audiotranscriptionslive)
for nginx and client constraints.
## Optional frontend modules and listeners
These are build-time extensions from the external
[server-frontends repository](https://github.com/0xShug0/audio.cpp-server-frontends),
not model options or part of the default server dependency set.
| Need | Build settings | Runtime settings |
|---|---|---|
| Audio decoding / MP3 encoding | `-DAUDIOCPP_BUILD_SERVER_FRONTENDS=ON -DAUDIOCPP_SERVER_FRONTENDS_DIR=external/audio.cpp-server-frontends -DAUDIOCPP_SERVER_FRONTEND_MODULES="audio_decode;mp3_encode"` | Use the extended audio formats on supported routes. |
| Frontend-owned transport, such as HTTPS or WebSocket | Same frontend build flags, with `-DAUDIOCPP_SERVER_FRONTEND_MODULES=<listener>` | `--frontend-listener <listener>` and repeated `--frontend-option key=value` |
Optional frontend modules are selected at configure time with the semicolon-separated
`AUDIOCPP_SERVER_FRONTEND_MODULES` list and an external
`AUDIOCPP_SERVER_FRONTENDS_DIR` package. If you use the bundled submodule path,
fetch it before configuring:
```bash
git submodule update --init external/audio.cpp-server-frontends
```
For a fresh clone, `git clone --recurse-submodules` also fetches it.
The server runs selected modules as an ordered pipeline: every module gets a
pre-processing pass before the core handler, then every module gets a
post-processing pass after the core handler. A module that does not need one side
leaves that method empty. Each active side declares a simple contract over the
HTTP envelope state (`method`, `path`, `request_in/request_out` for
pre-processing, or `response_in/response_out` for post-processing), and module
registration rejects incompatible adjacent transforms on the same route.
The external package owns modules and private dependencies such as miniaudio
and libmp3lame; the default server includes none of these modules or dependencies.
Listener frontends are selected through the same external package but are not
part of the pre/post pipeline. The server core only knows a listener name plus
string options; the external package owns listener implementations, docs, and
dependency detection.
Set top-level `"frontend_listener"` to select a compiled listener and
`"frontend_options"` to an object of listener-specific **string** values. The
equivalent command-line options are `--frontend-listener <name>` and repeated
`--frontend-option key=value`. Check the frontend repository for listener names
and required settings; adding config alone does not compile a missing module.
## Experimental features
Neither feature below is required for a multi-model config, the normal API, or
the same-origin embedded UI. Keep the default runtime unless you specifically
need to evaluate the experimental one.
### Experimental CORS
CORS is disabled by default. For trusted local browser demos on a different
origin from the server, enable it explicitly:
```bash
build/debug/bin/audiocpp_server --config server.json --cors-origins "*"
```
or add this server-level field to `server.json`:
```json
{
"cors_origins": "*"
}
```
> [!WARNING]
> CORS is not authentication. This experimental support is intended for trusted
> local web apps only. With `"*"`, any browser origin can make cross-origin API
> requests and consume local CPU/GPU resources. Do not expose the server on an
> untrusted network.
### Experimental parallel model slots
The single authoritative
[Parallel Server Behavior Contract](../../docs/maintainers/parallel_server_behavior_contract.md)
defines operation/state matrices, linked tests, validation by area and known
limitations. Enabling the feature prints a warning with the tested and untested
scope. Controlled session tests do not admit real catalogue models to multi-slot
execution; GPU streaming, full lifecycle coverage and hard backend cancellation
remain unverified. The descriptions below are a usage summary.
The existing implementation remains the default. Opt into the separate parallel
runtime at process startup:
```bash
audiocpp_server --config server.json --parallel-jobs
```
Without `--parallel-jobs`, **any explicit `slots` field** in a model entry or
dynamic registration is rejected, including `"slots": 1` and `"slots": null`.
Existing configurations without the field use the original `ServerState`.
With `--parallel-jobs`, every model uses `ParallelServerState`. Omitted slots
means capacity one; otherwise `"slots"` must be an integer from 1 to 16. Capacity
one also uses the new scheduler and session pool. Slot count never selects the
runtime. The requested count must fit the model's advertised parallel capacity;
the common framework retains capacity one unless the model explicitly supports
more. Increasing `slots` does not automatically enable concurrency for a model.
The default HTTP listener in this mode owns its request workers, interrupts
socket I/O on shutdown, and joins workers before destroying the runtime. Active
GPU work must still finish. Custom frontend listeners own their shutdown/drain
behavior through the existing frontend interface.
`GET /v1/models` on the parallel runtime reports configured `slots`, `active_slots`,
`queued_requests` and loaded `max_parallel_slots` (`null` when unloaded). Legacy
model responses retain their schema and do not gain synthetic slots fields.
Parallel requests and management share FIFO admission; unload, eviction and
reconfiguration drain active leases. Counts can be resized across one under an
exclusive lease, but registration never changes the process's selected runtime.
The inference CLI has no new flag and retains its existing behavior.
Each slot needs private execution contexts, graphs, KV/reference caches,
stream state and sampling state. Immutable assets/weights can be shared.
More slots can improve throughput but increase latency and working memory.
This is concurrent session execution; continuous token batching is not included.
See the [adapter guide](../../docs/maintainers/parallel_sessions.md).
The [validation procedure](../../docs/maintainers/parallel_model_validation.md)
keeps model quality/recovery/memory checks separate from shared framework
ownership tests and performance measurements. Shared evidence is referenced once
per applicable revision/backend; every admitted count still needs repeated
quality testing. Optional separate-server benchmarks do not determine admission.
The [busy-timeout policy](#busy-requests-and-timeouts) also applies here. Requests
queue when every slot is occupied or management blocks admission. When every
occupied slot is overdue, new requests fail fast with HTTP 503, or an error event
if streaming has started. Waiting unload/reconfiguration keeps priority but does
not suppress that check, even when the pool has spare slots. A healthy occupied
slot still permits bounded waiting; timeout `0` disables both checks.
All models (including capacity one) use FIFO admission. Earlier queued requests
drain before a later unload/reconfiguration, and later arrivals cannot overtake
a reserved waiter. A request binds model configuration before preparation and
retains its lease through any deferred stream/native batch response. Bulk unload
releases ready idle pools before waiting on busy ones. Logical residency ends
when teardown starts; unload completes when resources have been destroyed, and
physical-memory guards still apply. Registry lookup/snapshot locks never span
model admission or metadata waits. See the
[behavior contract](../../docs/maintainers/parallel_server_behavior_contract.md).

714
app/server/api.md Normal file
View file

@ -0,0 +1,714 @@
# Server API Reference
For installation, model configuration, CLI option mapping, voice setup and
troubleshooting, start with the [server guide](README.md). The
[endpoint index](README.md#endpoints) helps choose a route.
Unless stated otherwise, examples use `http://127.0.0.1:8080`. Model IDs must
refer to entries in your server config, with the task/mode required by the route.
## Contents
- [Readiness](#get-health) and [model discovery / available options](#get-v1models)
- [Speech generation, voice references, and streamed audio](#post-v1audiospeech)
- [Transcription](#post-v1audiotranscriptions) and [timestamps / speaker details](#post-v1audiotranscriptionsdetails)
- [Native transcription batches](#post-v1batchestranscriptions)
- [Forced alignment](#post-v1audioalignments)
- [Live PCM transcription and deployment requirements](#post-v1audiotranscriptionslive)
- [Voice discovery](#get-v1audiovoicesmodelid)
- [Generic tasks and artifacts](#post-v1tasksrun)
- [Generic streaming](#post-v1tasksstream) and [native batches](#post-v1tasksbatch)
- [Streaming recipes](#post-v1recipesrun)
- [Unload selected models](#post-v1tasksunload_models) or [all models](#post-v1tasksunload_all_models)
## `GET /health`
Returns server readiness and the number of configured models.
```json
{"status":"ok","backend":"cpu","models":1,"ui":true,"ui_management":false,"version":"dev","git":"1bbd8517","git_date":"2026-10-25"}
```
`version`, `git`, and `git_date` are the build values `audiocpp_server --version`
prints: the `AUDIOCPP_VERSION` CMake value (`dev` by default), the short commit,
and the commit date.
## `GET /v1/models`
Returns OpenAI-style model entries for the configured audio.cpp model ids.
Each entry's `configured` is `true` for an id the server config defines and
`false` for a model loaded later through `POST /v1/models/load`.
Each entry includes `device`, the effective backend device index after applying
an optional per-model override to the server default.
Two opt-in query flags add to each entry:
- `include_session_options=true` echoes the model's configured `session_options`.
- `include_params=true` adds `params`: the request options the model's spec
declares under `options.request`, as the spec writes them (`name`, `type`,
`required`, `description`, and `default`/`min`/`max` where given). An enum row
that names a `preset` also carries the preset's `values`. A model whose spec
declares no request options returns `"params": []`.
```bash
curl 'http://127.0.0.1:8080/v1/models?include_params=true'
```
The list describes options; it does not validate them. Requests are still checked
per model as before.
## `POST /v1/audio/speech`
OpenAI-style text-to-audio. The response is `audio/wav` by default.
The `model` field is a configured entry's `id`. `input` is the text to speak;
`voice` selects a [server preset, reference-library name, or model-native voice](README.md#voices).
Model-specific controls belong in `options` and must be accepted by that family.
For example, a BreezeTTS entry can receive `"options": {"instruct": "A calm narrator."}`.
The OpenAI-style top-level `instructions` field is distinct: the current server
maps it to the model option `instruction`. It is not a universal replacement for
`options.instruct`, and a model must accept that spelling. Use the model's
documented option names consistently with its configured request defaults.
Top-level `speed` (or `speaking_rate`) is a positive speech-rate multiplier when
the model spec declares support. Explicit values, including `1`, override the
configured speed default. For models without declared speed support, `1` is
accepted as omitted; other values are rejected. For Kokoro, use the
[GGUF published on September 22, 2026 (UTC)](https://huggingface.co/audio-cpp/audio.cpp-gguf/commit/406756ee8e3b16e902ce40112986c1010775f888)
or a later published version. Other model controls are not implied by OpenAI
API compatibility.
```bash
curl http://127.0.0.1:8080/v1/audio/speech \
-H 'Content-Type: application/json' \
-o out.wav \
-d '{
"model": "pocket-tts",
"input": "audio.cpp is serving this request through the framework runtime.",
"max_tokens": 96,
"seed": 1234
}'
```
For full `uint64` seed values, pass `seed` as a JSON string. JSON numeric seeds
below `2^53` are accepted, but larger JSON numbers may lose precision before
option parsing.
If no request voice is provided and the configured model has `default_voice_preset`, the server injects that preset automatically. Request-level `voice`, `voice_ref`, and `reference_text` override the configured default.
An empty string or `null` for `voice` is treated as omitted.
A preset's configured `options` fill missing request keys. Explicit request options override preset options with the same key. Different aliases are not merged; supplying conflicting aliases may produce a model validation error.
`voice_ref` accepts either a plain path string (server-side file) or an object with a `type`:
```json
"voice_ref": { "type": "path", "path": "voices/alice.wav" }
```
With `"type": "base64"`, the `data` field carries a base64-encoded WAV payload (a `data:audio/wav;base64,...` URI is also accepted), so cloning clients can inline the reference audio instead of staging a file on the server first. The decoded payload is limited to 5 MiB; larger references must use a path:
```bash
curl http://127.0.0.1:8080/v1/audio/speech \
-H 'Content-Type: application/json' \
-o out.wav \
-d '{
"model": "indextts2",
"input": "Cloned from an inline reference.",
"voice_ref": { "type": "base64", "data": "UklGRh..." },
"reference_text": "Transcript of the reference audio."
}'
```
Set `"response_format": "json"` to receive base64 WAV in a JSON response. In builds configured with `-DAUDIOCPP_BUILD_SERVER_FRONTENDS=ON -DAUDIOCPP_SERVER_FRONTENDS_DIR=external/audio.cpp-server-frontends -DAUDIOCPP_SERVER_FRONTEND_MODULES=mp3_encode`, `"response_format": "mp3"` returns `audio/mpeg` MP3 bytes for non-streaming speech requests.
For streaming-capable TTS models configured with `mode: "streaming"`, `stream_format` follows the OpenAI speech streaming shape:
```bash
curl -N http://127.0.0.1:8080/v1/audio/speech \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream' \
-d '{
"model": "voxcpm2-stream",
"input": "Stream this sentence as audio events.",
"response_format": "pcm",
"stream_format": "sse",
"options": {
"retry_badcase": false
}
}'
```
The SSE stream emits `speech.audio.delta` events with base64 PCM chunks, then `speech.audio.done`, then `data: [DONE]`. VoxCPM2 streaming requires `retry_badcase=false` because retrying a completed bad case is an offline-only behavior. Set `"stream_format": "audio"` with `"response_format": "pcm"` to receive raw PCM bytes over chunked transfer encoding instead.
Some versions of the optional `mp3_encode` frontend reject `response_format=pcm`
before the core handler. If this example returns that frontend error, use a
server built without that module for PCM streaming.
`POST /v1/audio/speech/live` is the live-ingest variant for speech-to-speech models: the request body is raw PCM sent with `Transfer-Encoding: chunked`, and the response can emit audio while the input stream is still open. `input` is the text a text-to-speech model speaks and is required for it. A speech-to-speech or voice-conversion entry (task `s2s`, `vc` or `svc`) takes it as an optional prompt, such as PersonaPlex's persona prompt or the system prompt of LFM2.5-Audio or LFM3-Audio, and without it the model uses its default. The route's `speech.audio.done` timing reports `ttft_ms` only when first output audio occurs after the input stream ends. If output audio starts before the input stream closes, `ttft_ms` is `null`, `first_audio_before_input_end=true`, and `overlap_ms` reports how much earlier the first output arrived. `request_start_to_first_audio_ms` is also included for transport diagnostics.
With `return_text=true` (or `1`), the stream also carries the text the model writes as it speaks, such as a spoken reply's words. `speech.text.delta` events carry the text written since the previous event, in order with the audio deltas (an event's text comes before its audio), and `speech.text.done` carries the whole text before `speech.audio.done`:
```text
data: {"type":"speech.text.delta","delta":"Sure! How about"}
data: {"type":"speech.audio.delta","audio":"<base64 PCM>"}
...
data: {"type":"speech.text.done","text":"Sure! How about ..."}
data: {"type":"speech.audio.done","timing":{...}}
data: [DONE]
```
Without `return_text` the stream is audio only. `return_text` needs `stream_format=sse`; a model that writes no text ends the stream with an error event after its audio.
## `POST /v1/audio/transcriptions`
JSON transcription request using a server-local WAV audio path.
```bash
curl http://127.0.0.1:8080/v1/audio/transcriptions \
-H 'Content-Type: application/json' \
-d '{
"model": "qwen3-asr",
"audio": "/path/to/input.wav"
}'
```
Also accepts a `multipart/form-data` upload, matching the OpenAI Whisper API convention used by real clients (e.g. Open WebUI). The request is routed to the multipart path based on the `Content-Type` header; the JSON path above still works unchanged.
```bash
curl http://127.0.0.1:8080/v1/audio/transcriptions \
-F model=qwen3-asr \
-F language=en \
-F file=@/path/to/input.wav
```
`file` and `model` are required; `language` is optional. Uploaded WAV bytes are decoded in memory and are not written to a temporary file. In builds configured with `-DAUDIOCPP_BUILD_SERVER_FRONTENDS=ON -DAUDIOCPP_SERVER_FRONTENDS_DIR=external/audio.cpp-server-frontends -DAUDIOCPP_SERVER_FRONTEND_MODULES=audio_decode`, the frontend also accepts MP3 and FLAC input for this route, decodes it to a temporary WAV, and forwards that normalized request to the same core transcription handler.
For streaming-capable ASR models configured with `mode: "streaming"`, pass `stream=true` to receive OpenAI-style transcription SSE:
```bash
# Nemotron requires mono 16 kHz audio; prepare it before uploading.
ffmpeg -i /path/to/input.wav -ar 16000 -ac 1 input-16k.wav
curl -N http://127.0.0.1:8080/v1/audio/transcriptions \
-F model=nemotron-stream \
-F language=en-US \
-F stream=true \
-F file=@input-16k.wav
```
The stream emits `transcript.text.delta` events, one final `transcript.text.done` event containing the full transcript, then `data: [DONE]`.
Models with revisable hypotheses, currently Phonon-2, may additionally emit
`{"type":"transcript.text.partial","text":"..."}`. This is a whole provisional
transcript to replace in the display, never append to the committed deltas.
For these models, deltas contain finalized segments only. Other models retain
their existing event behavior. See [Phonon-2](../../docs/models/parakeet_tdt.md#phonon-2-parakeet-tdt-variant).
`stream=true` streams the *output* of an already-uploaded file: the whole
recording is sent first, and the deltas describe decoding it. It can shorten
time to the first transcript on long audio, but nothing appears during the
upload. To send audio incrementally, use [live transcription](#post-v1audiotranscriptionslive).
## `POST /v1/audio/transcriptions/details`
Same request as `POST /v1/audio/transcriptions` — JSON with a server-local path, or a `multipart/form-data` upload — with a richer response. Use it when the model produces timestamps or speaker labels and the caller wants them.
`/v1/audio/transcriptions` returns `text` and `timing` and nothing else, so a model that aligned every word or separated speakers has that work discarded on the way out. This route returns those fields instead. The response schema of the plain route is unchanged; existing clients see exactly what they see today.
```bash
curl http://127.0.0.1:8080/v1/audio/transcriptions/details \
-F model=parakeet-tdt \
-F file=@/path/to/input.wav
```
```json
{
"text": "the task has completed successfully",
"language": "en",
"words": [
{"word": "the", "start_sample": 3200, "end_sample": 6400, "confidence": 0.98}
],
"sample_rate": 16000,
"timing": { "wall_ms": 412.7, "audio_duration_ms": 2400.0, "rtf": 0.17 }
}
```
`text` and `timing` are always present and match the plain route. The rest appear only when the model produced them:
| Field | Present when | Contents |
|---|---|---|
| `language` | the model reports a detected or configured language | Language code. |
| `segments` | the model produces speech segments | `start_sample`, `end_sample`, `confidence`, and `text` where the segment carries it. |
| `speaker_turns` | the model diarizes | `start_sample`, `end_sample`, `speaker_id`, `confidence`, and `text` where present. |
| `words` | the model aligns words | `word`, `start_sample`, `end_sample`, `confidence`. |
| `sample_rate` | any of the three arrays above is present | Rate the sample offsets are counted in. Divide an offset by it for seconds. |
Spans are sample offsets rather than seconds because that is what the models report; `sample_rate` is what converts them, which is why it only appears alongside them.
`stream=true` is rejected with a 400 on this route: the SSE response carries transcript deltas only, so it has nowhere to put the detail arrays. Use `/v1/audio/transcriptions` for a streamed transcript.
## `POST /v1/batches/transcriptions`
Runs multiple uploaded WAV files through one native offline model batch. This is
an audio.cpp extension, not an OpenAI API endpoint. The selected model must
implement native batching; the server rejects unsupported models instead of
silently running each file separately.
Supply `file` more than once in one multipart request:
```bash
ffmpeg -i /path/to/meeting-a.wav -ar 16000 -ac 1 meeting-a-16k.wav
ffmpeg -i /path/to/meeting-b.wav -ar 16000 -ac 1 meeting-b-16k.wav
curl -N http://127.0.0.1:8080/v1/batches/transcriptions \
-F model=nemotron-3-diar \
-F file=@meeting-a-16k.wav \
-F file=@meeting-b-16k.wav
```
Nemotron 3 Diarization requires mono 16 kHz WAV input. This endpoint does not
automatically resample uploaded WAV files.
`model` and at least one `file` are required. `language`, `prompt`,
`busy_timeout_ms`, and a JSON object in `options` are optional and apply to every
file. The response is an SSE stream. Each file is published as soon as the model
finishes it; `index` maps the result back to its upload position. The final event
contains aggregate batch timing measured against the combined audio duration.
```text
data: {"type":"batch.transcription.result","index":0,"filename":"meeting-a.wav","text":"","speaker_turns":[{"start_sample":0,"end_sample":32000,"speaker_id":"speaker_0","confidence":1.0}],"sample_rate":16000}
data: {"type":"batch.transcription.result","index":1,"filename":"meeting-b.wav","text":"","speaker_turns":[...],"sample_rate":16000}
data: {"type":"batch.transcription.done","result_count":2,"timing":{"wall_ms":145.5,"audio_duration_ms":70000.0,"rtf":0.0021}}
data: [DONE]
```
## `POST /v1/audio/alignments`
Multipart forced-alignment request using uploaded audio bytes and a known transcript. Use this when the server cannot see the client's local audio path, for example when the server is remote or running in Docker.
```bash
curl http://127.0.0.1:8080/v1/audio/alignments \
-F model=qwen3-align \
-F language=en \
-F text='The task has completed successfully.' \
-F file=@/path/to/input.wav
```
`file`, `model`, and `text` are required. `language` is model-dependent: Qwen3 Forced Aligner requires it (for example, `English` or `Chinese`). The selected model must be configured with `task: "align"` and `mode: "offline"`. Uploaded WAV bytes are decoded in memory and are not written to a temporary file. The response includes word timestamps in seconds plus sample offsets.
## `POST /v1/audio/transcriptions/live`
Streams raw PCM **as it is captured** and returns transcript deltas on the same connection, so partial text can appear while the user is still speaking.
The request body is raw interleaved PCM sent with `Transfer-Encoding: chunked`; the response is the same SSE event shape as `stream=true` above, so a client can share one reader. There is no multipart form and no file — the audio never has to exist on disk, and the transport hands each chunk to the model as it arrives rather than assembling the recording first.
Because the body carries audio rather than JSON, parameters are query parameters:
| parameter | default | meaning |
| --- | --- | --- |
| `model` | required | id of a model configured with `mode: "streaming"` |
| `sample_rate` | `16000` | samples per second of the PCM being sent |
| `channels` | `1` | interleaved channel count |
| `sample_format` | `s16le` | `s16le` or `f32le` |
| `language` | unset | passed through to the model |
| `prompt` | unset | URL-encoded recognition context (hotwords, spellings), same as the multipart `prompt` field |
| `busy_timeout_ms` | model policy | how long to wait for the model lock, as elsewhere; clamped by the configured ceiling, so a request can shorten its own wait but never weaken the guard |
```bash
# macOS microphone example; device selection is platform-specific.
ffmpeg -f avfoundation -i ":0" -ar 16000 -ac 1 -f s16le - \
| curl -N -X POST -H 'Expect:' -T - \
'http://127.0.0.1:8080/v1/audio/transcriptions/live?model=voxtral-realtime&sample_rate=16000&channels=1&sample_format=s16le'
```
On Linux, use the input arguments `-f alsa -i default` instead of
`-f avfoundation -i ":0"`; on Windows, select a DirectShow audio device with
`-f dshow -i audio="<device name>"`. For a reproducible file-fed check, replace
the microphone input arguments with `-i /path/to/input.wav` and keep the PCM
output options and curl pipe unchanged.
`-T -` is what makes this live, and it is not interchangeable with `--data-binary @-`: the latter drains stdin to completion before opening the connection, which turns a live capture back into a file upload and defeats the endpoint. `-H 'Expect:'` suppresses curl's `Expect: 100-continue`, which the server does not answer — without it curl waits out its one-second continue timeout before sending any audio. (`-T .` reads stdin non-blocking; measured against this endpoint it behaves the same, so either works.)
A headerless stream carries no format, so the parameters above are a contract the server cannot verify — sending 48 kHz audio while declaring 16 kHz produces a confident, wrong transcript rather than an error.
Whether partial text actually appears *during* capture is a property of the model, not of this endpoint. Cache-aware streaming models such as `voxtral_realtime` and `nemotron_asr` emit deltas throughout the utterance; buffered models may emit only after enough audio has accumulated.
For a model configured with `task: "diar"`, this route and the file-backed
`/v1/audio/transcriptions` route with `stream=true` return speaker turns instead
of text deltas:
```text
data: {"type":"diarization.delta","speaker_turns":[{"start_sample":5760,"end_sample":52480,"speaker_id":"speaker_0","confidence":0.99}],"sample_rate":16000}
data: {"type":"diarization.done","speaker_turns":[...],"sample_rate":16000,"timing":{"ttft_ms":3606}}
data: [DONE]
```
Each delta contains newly emitted turns. The final event contains the complete
result, including any turn still open when input ended; do not append it to the
deltas. TTFT measures the first speaker-turn result, not the first internal
probability prediction. A model that emits only completed turns waits for a turn
to end. If no turns are detected, the final array is empty and `ttft_ms` is `null`.
ASR responses keep their `transcript.text.delta` / `transcript.text.done` format.
The request ends when the client sends the terminating chunk. Closing the connection without one is an error, not an end of speech — a truncated transcript that arrives as a normal `transcript.text.done` would be indistinguishable from the speaker stopping, so the endpoint refuses to produce one. The same applies to a stall past the idle timeout, an oversized chunk, or a malformed frame: each surfaces as an SSE `error` event.
Because the model is held for the length of the request, the body is bounded on several axes:
| key | default | meaning |
| --- | --- | --- |
| `idle_timeout_ms` | 30 s | longest wait for more data once the reader asks for it |
| `total_timeout_ms` | 600 s | checked at every point the body advances, so it caps the whole request |
| `max_body_bytes` | 512 MiB | received body bytes, framing included |
| `max_chunk_bytes` | 8 MiB | largest single declared chunk |
| `send_timeout_ms` | 30 s | `SO_SNDTIMEO` on the connection, so a client that stops reading the SSE response cannot hold the model open |
Those defaults suit one dictation at a time. Continuous captioning needs a longer deadline, and a trusted deployment may want a different trade entirely, so they are configurable — server-wide under `live_ingest`, with any subset overridden per model:
```json
{
"live_ingest": { "total_timeout_ms": 600000, "max_chunk_bytes": 8388608 },
"models": [
{
"id": "voxtral-realtime",
"family": "voxtral_realtime",
"mode": "streaming",
"live_ingest": { "total_timeout_ms": 1800000 }
}
]
}
```
A model entry sets only the values it needs and inherits the rest, so raising one bound for a long-capture model does not mean restating the whole policy and letting it drift. `0` means *disabled*, the same convention as `busy_timeout_ms`; a negative value is rejected at startup rather than silently treated as "no bound". The exception is `max_chunk_bytes`, which must stay positive and is rejected at `0`: a chunk is materialized in memory before it is served, so an unbounded one is not implementable, and the same value backs the overflow check that rejects a declared chunk size of `SIZE_MAX`. The keys are only consulted for this route, since no other one delivers its body incrementally.
`sample_rate` and `channels` are range-checked too (1000–384000 and 1–16), and are not configurable: they size the model's per-chunk buffer, so an absurd value would be an allocation request made while the model lock is held.
**Deployment.** This endpoint streams the request body and the response on one connection at the same time. That is legal HTTP/1.1, but it needs a direct connection or a proxy that does not buffer requests. Behind nginx both of these are required, because `proxy_request_buffering off` still buffers a chunked body unless the upstream connection is HTTP/1.1:
```nginx
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_buffering off;
```
A browser cannot drive it: `fetch()` request streaming requires HTTP/2 and is half-duplex by specification — the whole request is sent before the response is processed. It is intended for native clients — `curl`, an `ffmpeg` pipe, or a backend service. A WebSocket transport would suit browsers and intermediaries better and could be added alongside this without changing it.
## `GET /v1/audio/voices?model=<id>`
Lists cached voice IDs, configured model preset names, and shared `voice_dir`
WAV basenames. If the configured model path is a directory containing
`embeddings/*.safetensors` (used by PocketTTS), their filename stems are also
listed. It does not enumerate speaker tensors packed inside a GGUF; use the
[model's documented built-in IDs](README.md#built-in-voices-and-discovery) for those.
Pass `model` explicitly when several entries are configured. Omitting it selects
the only model when exactly one exists; with several models it can return an
empty list instead. Shared library entries are not filtered to the model's
voice-cloning capabilities.
```bash
curl 'http://127.0.0.1:8080/v1/audio/voices?model=pocket-tts'
```
```json
{"voices": ["alba", "cosette", "marius"]}
```
## `POST /v1/tasks/run`
For audio input, `request.audio` is a server-local WAV path. Alternatively,
`request.audio_base64` accepts a Base64-encoded WAV or a
`data:audio/wav;base64,...` URI:
```json
{
"model": "mel_band_roformer",
"request": {"audio_base64": "<Base64-encoded WAV>"}
}
```
Provide only one of `audio` and `audio_base64`. Invalid inline audio returns
HTTP 400. The existing `max_request_body_bytes` limit applies to the entire
JSON body, including Base64 expansion. The WAV must meet the model's input
sample-rate and channel requirements. Inline audio is supported on this endpoint;
batch and stream endpoints retain their existing input formats.
Generic framework request route. The `request` object uses the same JSON fields as the `audiocpp_cli` request sequence format.
```bash
curl http://127.0.0.1:8080/v1/tasks/run \
-H 'Content-Type: application/json' \
-d '{
"model": "pocket-tts",
"request": {
"text": "Generic audio.cpp request.",
"voice_ref": "/path/to/reference.wav",
"max_tokens": 96,
"seed": 1234
}
}'
```
A result's artifacts (opaque payloads such as speaker embeddings or acoustic
tokens) come back in an `artifacts` array. A request takes artifacts in the
same shape, so a client can send what one result returned in a later request:
```json
{
"model": "my-model",
"request": {
"audio": "/path/to/input.wav",
"artifacts": [
{"id": "example.state", "kind": "custom", "payload": "<Base64 bytes>", "meta": {"format": "example/1"}},
{"id": "example.tokens", "kind": "acoustic_tokens", "path": "/path/to/tokens.bin"}
]
}
}
```
| field | meaning |
| --- | --- |
| `id` | non-empty string; ids may repeat |
| `kind` | `speaker_embedding`, `style_embedding`, `prompt_embedding`, `acoustic_tokens`, `midi`, `transcript_alignment`, `diarization_state`, `vad_state` or `custom` |
| `payload` | the bytes as Base64; a `data:...;base64,` URI is also accepted |
| `path` | instead of `payload`: a server-local file whose bytes are the payload; use an absolute path to avoid request-base ambiguity |
| `meta` | optional object of strings; a number or boolean value becomes text as in `options` (`12`, `true`; `1.0` becomes `1`) |
The model gets the artifacts in array order. Which ids and kinds it reads is up
to its family; see the model's docs. Most families that read none ignore them,
while AuK, HeartMuLa, VibeVoice, VoxCPM1, VoxCPM2 and YuE2 turn away a request
that carries any with HTTP 400. The same field works in `/v1/tasks/stream`
requests and in each `/v1/tasks/batch` entry. A malformed entry returns HTTP 400
naming it, for example `artifacts[1] (example.tokens): unknown kind 'tokens'`.
`max_request_body_bytes` bounds inline payloads, as it does `audio_base64`; a
`path` must name a regular file, and the payloads of one request, or of one
`/v1/tasks/batch` entry, may total at most 2 GiB. A batch reads all its entries
before it runs, so with `path` artifacts it can hold up to 2 GiB per entry.
Relative request paths resolve against the server's request base: normally its
working directory, or the executable directory with native UI management.
This differs from config-relative model paths. Absolute server-local paths work
in either mode.
## `POST /v1/tasks/stream`
Runs one generic request through the model's streaming interface and returns
the events it produced with the result. The body is the one `/v1/tasks/run`
takes, artifacts included, except that `audio_base64` is not read here: it is
ignored, not rejected, so audio input goes in `audio` as a server-local path.
By default the response is one JSON body, sent once the run is over:
```text
{"events":[{"partial_text":{"text":"Sure","language":"en"},"audio":"<Base64 WAV>","is_final":false},
{"audio":"<Base64 WAV>","is_final":false},...],
"result":{"text":"Sure! ...","language":"en","audio":"<Base64 WAV>","sample_rate":24000,"channels":1,
"timing":{"ttft_ms":<ms>}}}
```
An event has the fields it carries: `partial_text`, `audio` (a WAV of that
event's samples), `named_audio_outputs`, `word_timestamps` and, for a
diarization model, `speaker_turns`, and always `is_final`. With
`"include_artifacts": true`, artifact outputs are included in `artifacts`, using
the same id, kind, Base64 payload and metadata as the final result. Events do not carry voice activity,
so an event can be just `{"is_final":false}`.
`result` has the fields of the `/v1/tasks/run` result, except that its `timing`
holds only `ttft_ms`: the time from the start of the run, after the model is
free and loaded, to its first output (`null` for a diarization run that found
no speaker).
Set top-level `"include_artifacts": true` to include model artifacts in streamed
events and count artifact-only events toward first-output timing. It defaults to
false, preserving existing event fields and timing. This applies to both JSON
and SSE streaming responses; final-result artifact serialization is unchanged.
With `"stream_format": "sse"` at the top level of the body, the response is
server-sent events instead, each written as the model produces it:
```bash
curl -N http://127.0.0.1:8080/v1/tasks/stream \
-H 'Content-Type: application/json' \
-d '{
"model": "lfm2-audio-s2s-stream",
"stream_format": "sse",
"request": {"audio": "/path/to/question.wav", "options": {"return_codes": true}}
}'
```
```text
data: {"type":"task.stream.event","event":{"partial_text":{"text":"Sure","language":"en"},"audio":"<Base64 WAV>","is_final":false}}
data: {"type":"task.stream.event","event":{"audio":"<Base64 WAV>","is_final":false}}
...
data: {"type":"task.stream.done","result":{"text":"Sure! ...","language":"en","audio":"<Base64 WAV>",...,"timing":{"ttft_ms":<ms>}}}
data: [DONE]
```
Each `event` is the object the JSON response puts in `events`, in the same
order, and `result` is the object it puts under `result`, artifacts included.
`task.stream.done` carries the whole result on one line, so for a spoken reply
it repeats all of the reply's audio, about 640 KB of Base64 per 10 s of 24 kHz
audio; a client that reads lines with a fixed size limit has to allow for that.
A client that closes the stream before `task.stream.done` gets no result.
Only the string `"sse"` streams. Without the field, or with `null`, the
response is the JSON body, and any other value is answered with HTTP 400. This
differs from `/v1/audio/speech`, which streams whenever `stream_format` is
present. The `Accept` header is not consulted.
A request that fails before the stream starts gets the status it gets without
`stream_format`: an unknown model, a malformed artifact, an unreadable `audio`
path and, with `--parallel-jobs`, a model that stays busy past the busy timeout
(503). Once the stream has started, a failure ends it with
`data: {"type":"error","error":{"message":"..."}}`, without `task.stream.done`
or `[DONE]`; the event has no error type or status. That covers a model that
fails to load, a request the model turns away when it runs (HTTP 400 without
`stream_format`) and, without `--parallel-jobs`, a busy model, as on streamed
`/v1/audio/speech`. A client knows the run succeeded when it gets
`task.stream.done`.
An SSE client that disconnects stops the run within the next event or two; a
JSON client that disconnects leaves its run to finish. An SSE client that stops
reading keeps the model, or with `--parallel-jobs` its slot, busy until it
reads or disconnects, as on streamed `/v1/audio/speech` and
`/v1/audio/transcriptions`; `live_ingest.send_timeout_ms` applies only to the
live routes. The SSE response carries `X-Accel-Buffering: no`; a proxy in front
of the server must not buffer it.
## `POST /v1/tasks/batch`
Runs multiple generic requests through a model's native offline batch path. The
selected model must implement native batching; unsupported models are rejected
instead of being run sequentially. Each entry uses the same fields as the
`request` object accepted by `/v1/tasks/run`.
```bash
curl -N http://127.0.0.1:8080/v1/tasks/batch \
-H 'Content-Type: application/json' \
-d '{
"model": "reuse",
"requests": [
{"audio": "/path/to/first.wav"},
{"audio": "/path/to/second.wav"}
]
}'
```
The SSE response emits `task.batch.result` events with the original request
index as results become available, followed by `task.batch.done` with aggregate
batch timing. Per-result `timing` is `null` because fused execution does not
produce an independent wall time for each request.
## `POST /v1/tasks/unload_models`
Unload specific models from memory to free resources (e.g. VRAM on GPU backends). Subsequent requests to an unloaded model will trigger a transparent reload. The server waits for any in-flight inference on each target model to complete before unloading it.
The request body must contain a `model_ids` array of strings. Unknown ids are reported in the response rather than causing an error. Models that are not yet loaded (e.g. lazy-loaded models that have not been requested yet) are skipped silently.
```bash
curl http://127.0.0.1:8080/v1/tasks/unload_models \
-H 'Content-Type: application/json' \
-d '{
"model_ids": ["pocket-tts", "qwen3-asr"]
}'
```
Response:
```json
{
"unloaded": ["pocket-tts", "qwen3-asr"],
"not_found": []
}
```
## `POST /v1/tasks/unload_all_models`
Unload all currently loaded models from memory. No request body is required. As with the selective endpoint, subsequent requests will reload models transparently.
```bash
curl -X POST http://127.0.0.1:8080/v1/tasks/unload_all_models
```
Response:
```json
{
"unloaded": ["pocket-tts", "qwen3-asr"]
}
```
## `POST /v1/recipes/run`
Recipes connect named model roles. `GET /v1/recipes` lists available recipes
and their required tasks and modes. With `stream: true`, this endpoint returns
the same event/done SSE envelope as `/v1/tasks/stream`; set
`include_artifacts: true` to include streamed video payloads.
The `speaking-avatar` recipe connects a streaming TTS model to a streaming
`gen` model through a bounded PCM pipe. It starts avatar generation
while speech synthesis continues. Example with configured model IDs:
```json
{
"recipe": "speaking-avatar",
"stream": true,
"include_artifacts": true,
"models": {"tts": "chat-voice", "avatar": "chat-avatar"},
"request": {
"text": "Hello! How can I help you today?",
"voice_id": "american_english_female",
"options": {"avatar.reference_image_file": "/path/to/portrait.png"}
}
}
```
Use `tts.` and `avatar.` prefixes for role-specific request options.
`avatar_sample_rate` defaults to 16000. `speaking_speed` defaults to 1.0 and
accepts values from 0.5 to 2.0. Other speeds require libsonic for pitch-preserving
streaming tempo adjustment before both playback and avatar input. Normal speed
bypasses the processor without loading libsonic. Different producer/consumer rates require
libsoxr for continuous resampling; missing resampling support raises an error.
The final response includes the complete synthesized audio for replay, while
playback can begin from earlier streamed blocks.
The `voice-chat-avatar` recipe connects three streaming roles: `conversation`
(`s2s`), `tts` (`tts`) and `avatar` (`gen`). Conversation text is forwarded
immediately and queued incrementally for TTS using the framework's UTF-8/tag-aware
text chunker. TTS audio feeds the avatar while conversation generation continues;
there is no complete-reply barrier between stages.
Using model IDs from [the example config](../../examples/server/voice-chat-avatar.json):
```json
{
"recipe": "voice-chat-avatar",
"stream": true,
"include_artifacts": true,
"models": {
"conversation": "chat-conversation",
"tts": "chat-voice",
"avatar": "chat-avatar"
},
"request": {
"audio": "/path/to/question.wav",
"voice_id": "american_english_female",
"options": {
"conversation.return_codes": true,
"avatar.reference_image_file": "/path/to/portrait.png"
}
}
}
```
Audio and portrait paths refer to files accessible to the server. Optional
`request.text` is forwarded to the conversation model; LFM3 uses it as the
system prompt, not as text to synthesize. Prefix conversation-specific options
with `conversation.`. For LFM3, set
`conversation.return_codes: true` to retain its reply artifact. On subsequent
turns, send the accumulated `lfm3_audio.question` and `lfm3_audio.reply` artifacts
in `request.artifacts`, as described in the
[LFM3 conversation guide](../../docs/models/lfm3_audio.md). The recipe forwards
input artifacts only to the conversation role and retains that role's output
artifacts in its final response. All three roles require separate streaming
sessions.

View file

@ -0,0 +1,23 @@
#pragma once
#include <cstddef>
namespace minitts::server {
inline constexpr unsigned char kAudioCppDemo1ManWav[] = {
@AUDIOCPP_DEMO_1_MAN_BYTES@
};
inline constexpr unsigned char kAudioCppDemo2ManWav[] = {
@AUDIOCPP_DEMO_2_MAN_BYTES@
};
inline constexpr unsigned char kAudioCppDemo3WomanWav[] = {
@AUDIOCPP_DEMO_3_WOMAN_BYTES@
};
inline constexpr unsigned char kAudioCppDemo4WomanWav[] = {
@AUDIOCPP_DEMO_4_WOMAN_BYTES@
};
inline constexpr unsigned char kAudioCppDemoVoicePromptText[] = {
@AUDIOCPP_DEMO_PROMPT_TEXT_BYTES@
};
} // namespace minitts::server

View file

@ -0,0 +1,12 @@
#pragma once
#include <cstddef>
namespace minitts::server {
inline constexpr unsigned char kAudioCppUiHtml[] = {
@AUDIOCPP_UI_BYTES@
};
inline constexpr std::size_t kAudioCppUiHtmlSize = sizeof(kAudioCppUiHtml);
} // namespace minitts::server

View file

@ -0,0 +1,47 @@
#pragma once
#include "engine/framework/runtime/session.h"
#include <array>
#include <string_view>
namespace minitts::server {
struct AuditedModelSlots {
std::string_view family;
engine::runtime::VoiceTaskKind task;
size_t capacity;
};
// Model-specific validation adds entries in the model-support follow-up.
// The common framework alone does not opt any legacy model into parallel runs.
inline constexpr std::array<AuditedModelSlots, 0> kAuditedCudaOfflineModels = {};
inline constexpr std::array<AuditedModelSlots, 0> kAuditedVulkanOfflineModels = {};
inline size_t audited_slot_capacity(
std::string_view family,
engine::runtime::VoiceTaskKind task,
engine::core::BackendType backend,
engine::runtime::RunMode mode) noexcept {
if (mode != engine::runtime::RunMode::Offline) {
return 1;
}
const auto lookup = [&](const auto & entries) {
for (const auto & entry : entries) {
if (entry.family == family && entry.task == task) {
return entry.capacity;
}
}
return size_t{1};
};
switch (backend) {
case engine::core::BackendType::Cuda:
return lookup(kAuditedCudaOfflineModels);
case engine::core::BackendType::Vulkan:
return lookup(kAuditedVulkanOfflineModels);
default:
return 1;
}
}
} // namespace minitts::server

147
app/server/busy_guard.h Normal file
View file

@ -0,0 +1,147 @@
#pragma once
#include <atomic>
#include <chrono>
#include <cstdint>
#include <mutex>
#include <optional>
#include <stdexcept>
#include <string>
#include <string_view>
namespace minitts::server {
// Thrown when a model is occupied and the effective busy timeout has elapsed.
// Mapped to HTTP 503 so a client can retry or fail over rather than hang.
class ServerBusyError : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
// The host/GPU does not currently have enough free memory to load a model.
// Mapped to HTTP 503: a transient server condition the caller may retry after
// other models have been evicted or the system has freed memory.
class InsufficientMemoryError : public std::runtime_error {
public:
explicit InsufficientMemoryError(const std::string & message) : std::runtime_error(message) {}
};
inline std::int64_t steady_now_ms() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
}
// True when a request should fail fast instead of queuing behind the model lock:
// the current holder acquired it at busy_since_ms and has already run longer than
// timeout_ms. busy_since_ms == 0 means the model is idle; timeout_ms <= 0 disables
// the guard. Both times are steady-clock milliseconds.
inline bool model_run_has_overrun(std::int64_t busy_since_ms, std::int64_t now_ms, int timeout_ms) {
if (timeout_ms <= 0 || busy_since_ms == 0) {
return false;
}
return (now_ms - busy_since_ms) > timeout_ms;
}
// Resolve the timeout for a single run. `ceiling` is server policy (the top-level
// config value, overridden per model); `requested` is the optional per-request
// value. A request may ask for a shorter bound than policy but never a longer one,
// so a client cannot weaken the guard. 0 means "unbounded" and therefore compares
// as +infinity on both sides: a request asking for unbounded is still capped by
// policy, and under an unbounded policy a request's own bound is honored.
inline int resolve_busy_timeout_ms(int ceiling, std::optional<int> requested) {
if (!requested.has_value()) {
return ceiling;
}
const int value = *requested;
if (value <= 0) {
return ceiling;
}
if (ceiling <= 0) {
return value;
}
return value < ceiling ? value : ceiling;
}
// Serializes runs on one model and bounds how long a caller waits for its turn.
//
// A single model runs one request at a time. If a running inference wedges the GPU
// -- a CUDA call that never returns cannot be cancelled from userspace -- later
// requests would otherwise block forever and every worker thread would pile up
// behind it. With a positive timeout, a caller instead gives up and throws
// ServerBusyError (-> HTTP 503). A timeout of 0 restores the original unbounded
// std::mutex behavior.
class BusyGuard {
public:
// Holds the guard for the duration of a run and marks it idle again when it
// releases (including on exception), so the fail-fast check never sees a stale
// timestamp. Move-only.
class Lock {
public:
Lock() = default;
Lock(BusyGuard & guard, std::unique_lock<std::timed_mutex> lock)
: guard_(&guard), lock_(std::move(lock)) {}
Lock(Lock &&) = default;
Lock & operator=(Lock &&) = default;
Lock(const Lock &) = delete;
Lock & operator=(const Lock &) = delete;
~Lock() {
if (guard_ != nullptr && lock_.owns_lock()) {
guard_->busy_since_ms_.store(0, std::memory_order_release);
}
}
private:
BusyGuard * guard_ = nullptr;
std::unique_lock<std::timed_mutex> lock_;
};
// `label` identifies the model in the error message. Throws ServerBusyError when
// timeout_ms is positive and either the current holder has already overrun (fail
// fast, no wait) or the wait itself times out.
Lock acquire(int timeout_ms, std::string_view label) {
std::unique_lock<std::timed_mutex> lock(mutex_, std::defer_lock);
if (timeout_ms <= 0) {
lock.lock(); // guard disabled: original unbounded wait
} else {
// If the current holder has already run past the timeout, treat it as
// wedged and fail fast rather than queue behind it -- this is what stops
// requests piling up on a stuck GPU.
const auto since = busy_since_ms_.load(std::memory_order_acquire);
const auto now_ms = steady_now_ms();
if (model_run_has_overrun(since, now_ms, timeout_ms)) {
throw ServerBusyError(
"model '" + std::string(label) + "' is busy: current inference has run " +
std::to_string(now_ms - since) + " ms (busy_timeout_ms=" +
std::to_string(timeout_ms) +
"); the previous request has likely wedged and cannot be cancelled");
}
if (!lock.try_lock_for(std::chrono::milliseconds(timeout_ms))) {
throw ServerBusyError(
"model '" + std::string(label) + "' is busy: timed out after " +
std::to_string(timeout_ms) + " ms waiting for the inference lock");
}
}
busy_since_ms_.store(steady_now_ms(), std::memory_order_release);
return Lock(*this, std::move(lock));
}
// Non-blocking variant: the lock only if the model is idle right now, nullopt
// otherwise. Eviction sweeps use this so a caller that already holds one
// model's guard never waits on another's -- two loads evicting each other's
// target would otherwise deadlock.
std::optional<Lock> try_acquire() {
std::unique_lock<std::timed_mutex> lock(mutex_, std::try_to_lock);
if (!lock.owns_lock()) {
return std::nullopt;
}
busy_since_ms_.store(steady_now_ms(), std::memory_order_release);
return Lock(*this, std::move(lock));
}
private:
std::timed_mutex mutex_;
std::atomic<std::int64_t> busy_since_ms_{0};
};
} // namespace minitts::server

464
app/server/config.cpp Normal file
View file

@ -0,0 +1,464 @@
#include "config.h"
#include "../cli/args.h"
#include "../cli/request.h"
#include "engine/framework/io/json.h"
#include <cmath>
#include <cstdint>
#include <limits>
#include <stdexcept>
#include <utility>
namespace minitts::server {
// The request reader caps the artifact payloads of one request at the default
// body limit, so the two have to move together.
static_assert(minitts::cli::kMaxRequestArtifactBytes == kDefaultMaxRequestBodyBytes,
"request artifact payload limit differs from the default request body limit");
namespace {
std::filesystem::path resolve_path(const std::filesystem::path & base, const std::filesystem::path & path) {
return path.is_absolute() ? path : base / path;
}
std::unordered_map<std::string, std::string> options_from_object(const engine::io::json::Value * value) {
return minitts::cli::json_options_map(value);
}
uint64_t parse_max_request_body_bytes(const engine::io::json::Value & value) {
if (!value.is_number()) {
throw std::runtime_error("server max_request_body_bytes must be a number");
}
const double parsed = value.as_number();
constexpr double kMaxSafeJsonInteger = 9007199254740991.0; // 2^53 - 1
if (parsed < 0.0) {
throw std::runtime_error("server max_request_body_bytes must be non-negative");
}
if (std::floor(parsed) != parsed) {
throw std::runtime_error("server max_request_body_bytes must be an integer");
}
if (parsed > kMaxSafeJsonInteger) {
throw std::runtime_error("server max_request_body_bytes must be <= 2^53 - 1");
}
return static_cast<uint64_t>(parsed);
}
ServerModelConfig::VoicePreset parse_voice_preset(
const std::filesystem::path & base,
const engine::io::json::Value & value,
const std::string & context) {
if (!value.is_object()) {
throw std::runtime_error(context + " must be an object");
}
ServerModelConfig::VoicePreset preset;
if (const auto * voice_id = value.find("voice_id")) {
preset.voice_id = voice_id->as_string();
}
if (const auto * voice_ref = value.find("voice_ref")) {
preset.voice_ref = resolve_path(base, voice_ref->as_string());
}
if (const auto * reference_text = value.find("reference_text")) {
preset.reference_text = reference_text->as_string();
}
if (const auto * options = value.find("options")) {
if (!options->is_object()) {
throw std::runtime_error(context + " options must be an object");
}
preset.options = options_from_object(options);
}
if (!preset.voice_id.has_value() && !preset.voice_ref.has_value() && !preset.reference_text.has_value() &&
preset.options.empty()) {
throw std::runtime_error(context + " must set voice_id, voice_ref, reference_text, or options");
}
return preset;
}
// Every live-ingest bound uses 0 to mean "disabled", matching busy_timeout_ms, so
// only a negative value is malformed. Rejected at parse time rather than clamped:
// a negative deadline is a typo, and silently treating it as "no bound" would
// remove a guard the operator believed they had set.
//
// Read and validated by hand rather than through optional_i32/optional_i64, both
// of which are wrong here in two ways. They return the supplied fallback for a
// present-but-wrong-typed field, so `"max_body_bytes": "oops"` in a MODEL override
// would silently record the compiled default as a deliberate override and widen a
// stricter server policy. And optional_i32 narrows to int before anything checks
// the range, so on a 32-bit int a value of 4294967296 becomes 0 — which here means
// "disabled", quietly removing the bound the operator was trying to set.
double live_ingest_number(
const engine::io::json::Value & value,
const char * key,
const std::string & context) {
const auto * field = value.find(key);
if (field == nullptr || !field->is_number()) {
throw std::runtime_error(context + " " + key + " must be a number");
}
const double parsed = field->as_number();
constexpr double kMaxSafeJsonInteger = 9007199254740991.0; // 2^53 - 1
if (std::floor(parsed) != parsed) {
throw std::runtime_error(context + " " + key + " must be an integer");
}
if (parsed < 0.0) {
throw std::runtime_error(context + " " + key + " must be >= 0 (0 disables the bound)");
}
if (parsed > kMaxSafeJsonInteger) {
throw std::runtime_error(context + " " + key + " must be <= 2^53 - 1");
}
return parsed;
}
int live_ingest_ms(
const engine::io::json::Value & value,
const char * key,
int fallback,
const std::string & context) {
if (value.find(key) == nullptr) {
return fallback;
}
const double parsed = live_ingest_number(value, key, context);
if (parsed > static_cast<double>(std::numeric_limits<int>::max())) {
throw std::runtime_error(
context + " " + key + " must be <= " + std::to_string(std::numeric_limits<int>::max()) + " ms");
}
return static_cast<int>(parsed);
}
size_t live_ingest_bytes(
const engine::io::json::Value & value,
const char * key,
size_t fallback,
const std::string & context) {
if (value.find(key) == nullptr) {
return fallback;
}
const double parsed = live_ingest_number(value, key, context);
// Range-checked against size_t rather than cast blindly: on a 32-bit target a
// legal-looking 4294967296 would otherwise truncate to 0 and disable the bound.
if (parsed > static_cast<double>(std::numeric_limits<size_t>::max())) {
throw std::runtime_error(context + " " + key + " is too large for this platform");
}
return static_cast<size_t>(parsed);
}
LiveIngestLimits parse_live_ingest_limits(
const engine::io::json::Value & value,
const LiveIngestLimits & fallback,
const std::string & context) {
if (!value.is_object()) {
throw std::runtime_error(context + " must be an object");
}
LiveIngestLimits limits;
limits.idle_timeout_ms = live_ingest_ms(value, "idle_timeout_ms", fallback.idle_timeout_ms, context);
limits.total_timeout_ms = live_ingest_ms(value, "total_timeout_ms", fallback.total_timeout_ms, context);
limits.max_body_bytes = live_ingest_bytes(value, "max_body_bytes", fallback.max_body_bytes, context);
limits.max_chunk_bytes = live_ingest_bytes(value, "max_chunk_bytes", fallback.max_chunk_bytes, context);
// The one bound that cannot be disabled. A chunk is materialized in memory
// before it is served, so "unbounded" is not implementable — and a 0 here would
// underflow the overflow guard in the chunk-size parser, re-admitting a declared
// size of SIZE_MAX. Rejected rather than quietly substituted.
if (limits.max_chunk_bytes == 0) {
throw std::runtime_error(
context + " max_chunk_bytes must be > 0: a chunk is held in memory, so it cannot be unbounded");
}
limits.send_timeout_ms = live_ingest_ms(value, "send_timeout_ms", fallback.send_timeout_ms, context);
return limits;
}
LiveIngestOverrides parse_live_ingest_overrides(
const engine::io::json::Value & value,
const std::string & context) {
if (!value.is_object()) {
throw std::runtime_error(context + " must be an object");
}
// Parsed against the compiled-in defaults purely to reuse the validation; only
// the keys actually present are recorded, so the rest still fall through to
// whatever server policy is at the time the override is applied.
const LiveIngestLimits defaults;
const auto parsed = parse_live_ingest_limits(value, defaults, context);
LiveIngestOverrides overrides;
if (value.find("idle_timeout_ms") != nullptr) {
overrides.idle_timeout_ms = parsed.idle_timeout_ms;
}
if (value.find("total_timeout_ms") != nullptr) {
overrides.total_timeout_ms = parsed.total_timeout_ms;
}
if (value.find("max_body_bytes") != nullptr) {
overrides.max_body_bytes = parsed.max_body_bytes;
}
if (value.find("max_chunk_bytes") != nullptr) {
overrides.max_chunk_bytes = parsed.max_chunk_bytes;
}
if (value.find("send_timeout_ms") != nullptr) {
overrides.send_timeout_ms = parsed.send_timeout_ms;
}
return overrides;
}
} // namespace
LiveIngestLimits resolve_live_ingest_limits(
const LiveIngestLimits & base,
const LiveIngestOverrides & overrides) {
LiveIngestLimits limits = base;
if (overrides.idle_timeout_ms.has_value()) {
limits.idle_timeout_ms = *overrides.idle_timeout_ms;
}
if (overrides.total_timeout_ms.has_value()) {
limits.total_timeout_ms = *overrides.total_timeout_ms;
}
if (overrides.max_body_bytes.has_value()) {
limits.max_body_bytes = *overrides.max_body_bytes;
}
if (overrides.max_chunk_bytes.has_value()) {
limits.max_chunk_bytes = *overrides.max_chunk_bytes;
}
if (overrides.send_timeout_ms.has_value()) {
limits.send_timeout_ms = *overrides.send_timeout_ms;
}
return limits;
}
engine::core::BackendType parse_server_backend(const std::string & value) {
auto backend = minitts::cli::parse_backend(value);
if (backend == engine::core::BackendType::BestAvailable) {
throw std::runtime_error("unsupported server backend: " + value);
}
return backend;
}
ServerConfig load_server_config(const std::filesystem::path & path) {
return load_server_config(path, false);
}
ServerConfig load_server_config(const std::filesystem::path & path, bool parallel_jobs) {
const auto root = engine::io::json::parse_file(path);
const auto base = path.parent_path();
ServerConfig config;
config.host = engine::io::json::optional_string(root, "host", config.host);
config.port = engine::io::json::optional_i32(root, "port", config.port);
config.cors_origins = engine::io::json::optional_string(root, "cors_origins", config.cors_origins);
config.ui_enabled = engine::io::json::optional_bool(root, "ui", config.ui_enabled);
config.ui_requested = engine::io::json::optional_bool(root, "ui", false);
config.ui_management = engine::io::json::optional_bool(root, "ui_management", config.ui_management);
config.backend = parse_server_backend(engine::io::json::optional_string(root, "backend", "cuda"));
config.backend_impl = engine::io::json::optional_string(root, "backend_impl", "");
config.device = engine::io::json::optional_i32(root, "device", config.device);
config.threads = engine::io::json::optional_i32(root, "threads", config.threads);
config.lazy_load = engine::io::json::optional_bool(root, "lazy_load", config.lazy_load);
config.log_request_body = engine::io::json::optional_bool(root, "log_request_body", config.log_request_body);
if (const auto * value = root.find("max_request_body_bytes")) {
config.max_request_body_bytes = parse_max_request_body_bytes(*value);
}
config.busy_timeout_ms = engine::io::json::optional_i32(root, "busy_timeout_ms", config.busy_timeout_ms);
config.max_loaded_models = engine::io::json::optional_i32(root, "max_loaded_models", config.max_loaded_models);
config.idle_unload_ms = engine::io::json::optional_i32(root, "idle_unload_ms", config.idle_unload_ms);
config.min_free_memory_mb = engine::io::json::optional_i32(root, "min_free_memory_mb", config.min_free_memory_mb);
if (const auto * value = root.find("resample_kernel_cache_mb")) {
// Not require_i32: it narrows first, so 4294967296 would become 0 and disable the cache.
const double mb = value->is_number() ? value->as_number() : -1.0;
if (mb < 0.0 || mb > std::numeric_limits<int>::max() || std::floor(mb) != mb) {
throw std::runtime_error(
"server resample_kernel_cache_mb must be an integer in 0.." +
std::to_string(std::numeric_limits<int>::max()) + " (0 disables the cache)");
}
config.resample_kernel_cache_mb = static_cast<int>(mb);
}
if (const auto * value = root.find("live_ingest")) {
config.live_ingest = parse_live_ingest_limits(*value, config.live_ingest, "server live_ingest");
}
if (const auto * value = root.find("model_spec_override")) {
const auto & selection = value->as_string();
config.model_spec_override = selection == "embedded"
? std::filesystem::path(selection) : resolve_path(base, selection);
}
if (const auto * value = root.find("default_session_options")) {
if (!value->is_object()) {
throw std::runtime_error("server default_session_options must be an object");
}
config.default_session_options = options_from_object(value);
for (const auto & [key, option] : config.default_session_options) {
(void) option;
const auto dot = key.find('.');
if (dot == std::string::npos || dot == 0 || dot + 1 == key.size()) {
throw std::runtime_error(
"server default_session_options key must be <family>.<option>: " + key);
}
}
}
if (const auto * value = root.find("voice_dir")) {
if (!value->is_string()) {
throw std::runtime_error("server voice_dir must be a string");
}
config.voice_dir = resolve_path(base, value->as_string());
}
config.frontend_listener = engine::io::json::optional_string(root, "frontend_listener", config.frontend_listener);
if (const auto * value = root.find("frontend_options")) {
if (!value->is_object()) {
throw std::runtime_error("server frontend_options must be an object");
}
for (const auto & [key, option] : value->as_object()) {
if (!option.is_string()) {
throw std::runtime_error("server frontend_options values must be strings");
}
config.frontend_options[key] = option.as_string();
}
}
if (config.port <= 0 || config.port > 65535) {
throw std::runtime_error("server port must be in 1..65535");
}
if (config.busy_timeout_ms < 0) {
throw std::runtime_error("server busy_timeout_ms must be >= 0 (0 disables the guard)");
}
if (config.max_loaded_models < 0) {
throw std::runtime_error("server max_loaded_models must be >= 0 (0 disables the limit)");
}
if (config.idle_unload_ms < 0) {
throw std::runtime_error("server idle_unload_ms must be >= 0 (0 disables idle unload)");
}
if (config.min_free_memory_mb < 0) {
throw std::runtime_error("server min_free_memory_mb must be >= 0 (0 disables the memory guard)");
}
if (config.frontend_listener.empty() && !config.frontend_options.empty()) {
throw std::runtime_error("server frontend_options requires frontend_listener");
}
if (config.threads <= 0) {
throw std::runtime_error("server threads must be positive");
}
const auto * models = root.find("models");
if (models == nullptr || !models->is_array()) {
throw std::runtime_error("server config requires a models array");
}
if (models->as_array().empty() && !config.ui_management) {
throw std::runtime_error("server config requires a non-empty models array unless ui_management is enabled");
}
for (const auto & item : models->as_array()) {
ServerModelConfig model;
model.id = engine::io::json::require_string(item, "id");
model.family = engine::io::json::require_string(item, "family");
const auto raw_path = engine::io::json::require_string(item, "path");
model.path = resolve_path(base, raw_path);
if (const auto * value = item.find("model_spec_override")) {
const auto & selection = value->as_string();
model.model_spec_override = selection == "embedded"
? std::filesystem::path(selection) : resolve_path(base, selection);
}
model.task = engine::io::json::optional_string(item, "task", model.task);
model.mode = engine::io::json::optional_string(item, "mode", model.mode);
model.lazy = engine::io::json::optional_bool(item, "lazy", config.lazy_load);
if (item.find("device") != nullptr) {
const int device = engine::io::json::optional_i32(item, "device", config.device);
if (device < 0) {
throw std::runtime_error("device for model " + model.id + " must be >= 0");
}
model.device = device;
}
if (!parallel_jobs && item.find("slots") != nullptr) {
throw std::runtime_error("slots requires --parallel-jobs at server startup");
}
if (parallel_jobs) {
if (const auto * value = item.find("slots")) {
if (!value->is_number() || !std::isfinite(value->as_number()) ||
std::floor(value->as_number()) != value->as_number() ||
value->as_number() < 1 || value->as_number() > 16) {
throw std::runtime_error("model slots must be an integer between 1 and 16");
}
model.slots = static_cast<int>(value->as_number());
}
}
if (item.find("busy_timeout_ms") != nullptr) {
const auto busy_timeout_ms = engine::io::json::optional_i32(item, "busy_timeout_ms", 0);
if (busy_timeout_ms < 0) {
throw std::runtime_error(
"busy_timeout_ms for model " + model.id + " must be >= 0 (0 disables the guard)");
}
model.busy_timeout_ms = busy_timeout_ms;
}
if (const auto * value = item.find("live_ingest")) {
model.live_ingest = parse_live_ingest_overrides(*value, "live_ingest for model " + model.id);
}
if (const auto * value = item.find("config")) {
model.config_id = value->as_string();
}
if (const auto * value = item.find("weight")) {
model.weight_id = value->as_string();
}
model.load_options = options_from_object(item.find("load_options"));
model.session_options = options_from_object(item.find("session_options"));
model.default_request_options = options_from_object(item.find("default_request_options"));
if (const auto * value = item.find("instruct")) {
if (model.default_request_options.find("instruct") != model.default_request_options.end() ||
model.default_request_options.find("instruction") != model.default_request_options.end()) {
throw std::runtime_error(
"instruct for model " + model.id
+ " conflicts with an instruction in default_request_options");
}
model.default_request_options.emplace("instruct", value->as_string());
}
if (const auto * voice_presets = item.find("voice_presets")) {
if (!voice_presets->is_object()) {
throw std::runtime_error("voice_presets for model " + model.id + " must be an object");
}
for (const auto & [name, preset_value] : voice_presets->as_object()) {
if (name.empty()) {
throw std::runtime_error("voice_presets for model " + model.id + " cannot use an empty preset name");
}
auto [it, inserted] = model.voice_presets.emplace(
name,
parse_voice_preset(base, preset_value, "voice preset " + name + " for model " + model.id));
if (!inserted) {
throw std::runtime_error("duplicate voice preset for model " + model.id + ": " + name);
}
(void) it;
}
}
if (const auto * default_voice_preset = item.find("default_voice_preset")) {
if (default_voice_preset->is_string()) {
model.default_voice_preset_id = default_voice_preset->as_string();
if (model.default_voice_preset_id->empty()) {
throw std::runtime_error("default_voice_preset for model " + model.id + " cannot be empty");
}
if (model.voice_presets.find(*model.default_voice_preset_id) == model.voice_presets.end()) {
throw std::runtime_error(
"default_voice_preset for model " + model.id +
" does not match a configured voice_presets entry: " +
*model.default_voice_preset_id);
}
} else {
model.default_voice_preset =
parse_voice_preset(base, *default_voice_preset, "default_voice_preset for model " + model.id);
}
}
config.models.push_back(std::move(model));
}
for (auto & model : config.models) {
apply_default_session_options(config, model);
}
return config;
}
void apply_default_session_options(const ServerConfig & config, ServerModelConfig & model) {
const std::string prefix = model.family + ".";
for (const auto & [key, value] : config.default_session_options) {
if (key.rfind(prefix, 0) == 0) {
model.session_options.emplace(key, value);
}
}
}
void inherit_configured_model_fields(const ServerModelConfig & configured, ServerModelConfig & model) {
if (configured.family != model.family || configured.task != model.task) {
return;
}
model.busy_timeout_ms = configured.busy_timeout_ms;
model.live_ingest = configured.live_ingest;
model.default_request_options = configured.default_request_options;
model.voice_presets = configured.voice_presets;
model.default_voice_preset = configured.default_voice_preset;
model.default_voice_preset_id = configured.default_voice_preset_id;
}
} // namespace minitts::server

146
app/server/config.h Normal file
View file

@ -0,0 +1,146 @@
#pragma once
#include <cstdint>
#include <filesystem>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
#include "engine/framework/core/backend.h"
#include "frontend.h"
#include "http.h"
namespace minitts::server {
constexpr uint64_t kDefaultMaxRequestBodyBytes = 2ull * 1024ull * 1024ull * 1024ull;
// Per-model overrides of the server-level `live_ingest` block. Each field is
// independently optional: a model that only needs a longer deadline says so and
// inherits the rest, rather than restating the whole policy and drifting from it.
struct LiveIngestOverrides {
std::optional<int> idle_timeout_ms;
std::optional<int> total_timeout_ms;
std::optional<size_t> max_body_bytes;
std::optional<size_t> max_chunk_bytes;
std::optional<int> send_timeout_ms;
};
// Server policy with any per-model override applied on top.
LiveIngestLimits resolve_live_ingest_limits(
const LiveIngestLimits & base,
const LiveIngestOverrides & overrides);
struct ServerModelConfig {
struct VoicePreset {
std::optional<std::string> voice_id;
std::optional<std::filesystem::path> voice_ref;
std::optional<std::string> reference_text;
// Request options the preset supplies, passed to the model as given.
std::unordered_map<std::string, std::string> options;
};
std::string id;
std::filesystem::path path;
std::optional<std::filesystem::path> model_spec_override;
std::string family;
std::string task = "tts";
std::string mode = "offline";
bool lazy = false;
// Overrides ServerConfig::device for this model. When absent the server-wide
// device remains the default, preserving existing single-device configs.
std::optional<int> device;
int slots = 1; // Capacity read only by the startup-selected parallel runtime.
// Overrides ServerConfig::busy_timeout_ms for this model, and acts as the ceiling
// a per-request busy_timeout_ms is clamped to. Model runtimes differ by orders of
// magnitude (a short TTS clip vs. minutes of music generation), so one fleet-wide
// bound is either too tight for the slow models or useless for the fast ones.
std::optional<int> busy_timeout_ms;
// Only meaningful for a streaming model reachable over the live-ingest route;
// ignored otherwise, since no other route delivers its body incrementally.
LiveIngestOverrides live_ingest;
std::optional<std::string> config_id;
std::optional<std::string> weight_id;
std::unordered_map<std::string, std::string> load_options;
std::unordered_map<std::string, std::string> session_options;
std::unordered_map<std::string, std::string> default_request_options;
std::unordered_map<std::string, VoicePreset> voice_presets;
std::optional<VoicePreset> default_voice_preset;
std::optional<std::string> default_voice_preset_id;
};
struct ServerConfig {
std::string host = "127.0.0.1";
int port = 8080;
std::string cors_origins = "";
bool ui_enabled = true;
// Whether the UI was asked for (--ui, or "ui": true in the config file) rather
// than on by default.
bool ui_requested = false;
bool ui_management = false;
engine::core::BackendType backend = engine::core::BackendType::Cuda;
std::string backend_impl;
int device = 0;
int threads = 1;
bool lazy_load = false;
bool log_request_body = false;
uint64_t max_request_body_bytes = kDefaultMaxRequestBodyBytes;
// A single model runs one request at a time (serialized on model.mutex). If a
// running inference wedges the GPU -- a CUDA call that never returns cannot be
// cancelled from userspace -- later requests would otherwise block forever, so
// once the current run has held the lock this long a new request fails fast with
// 503 instead of parking a worker thread. Must exceed the slowest legitimate
// single inference (music generation can take minutes). 0 disables the guard and
// restores unbounded waiting.
int busy_timeout_ms = 300000;
// Upper bound on how many models may be resident in memory at once. Loading a
// model past the limit first unloads the least recently used idle model (its
// next request reloads it), so a multi-model config can run on a device that
// only fits a few of them. 1 enforces a single resident model. 0 disables the
// limit and keeps the original behavior: once loaded, a model stays in memory
// until it is unloaded explicitly or the server exits.
int max_loaded_models = 0;
// Unload every resident model once the server has been idle this long without
// any model load/run (steady-clock ms). 0 disables idle unload. Complements
// max_loaded_models: that bounds peak residency, this frees memory during
// quiet periods. The next request reloads lazily.
int idle_unload_ms = 0;
// Minimum free memory (host and GPU, each) the server must retain after
// loading a model, in MiB. Before every lazy load the server estimates the
// model's resident footprint from its weights and refuses to load when
// estimate + this headroom would not fit. 0 disables the memory guard
// entirely (the default), so existing deployments see no behavior change
// unless they opt in.
int min_free_memory_mb = 0;
// Memory for cached resampling kernels in MiB, 0 disables the cache; unset keeps the library default.
std::optional<int> resample_kernel_cache_mb;
// Fleet-wide bounds for incrementally delivered request bodies. The defaults are
// in LiveIngestLimits; a model entry may override any subset of them.
LiveIngestLimits live_ingest;
std::string frontend_listener;
ServerFrontendOptions frontend_options;
std::optional<std::filesystem::path> model_spec_override;
// Session options for every model of a family that does not set them itself:
// the configured models and the ones loaded later through POST /v1/models/load
// (the WebUI). Keys are family-qualified, e.g. "irodori_tts.mem_saver".
std::unordered_map<std::string, std::string> default_session_options;
// Voice library shared across all TTS models: *.wav files plus a `prompt_text`
// mapping file (<basename>|<transcript>). A request `voice` name that is not a
// model preset resolves to <voice_dir>/<name>.wav as the cloning reference.
std::optional<std::filesystem::path> voice_dir;
std::vector<ServerModelConfig> models;
};
engine::core::BackendType parse_server_backend(const std::string & value);
ServerConfig load_server_config(const std::filesystem::path & path);
ServerConfig load_server_config(const std::filesystem::path & path, bool parallel_jobs);
// Adds the config.default_session_options entries for model.family that the model's
// own session_options do not set.
void apply_default_session_options(const ServerConfig & config, ServerModelConfig & model);
// A WebUI load describes a model only by what its request carries. For an id the
// server config defines, takes the fields such a request cannot carry from that
// definition, while the load is still the same kind of model (family and task).
void inherit_configured_model_fields(const ServerModelConfig & configured, ServerModelConfig & model);
} // namespace minitts::server

56
app/server/example.json Normal file
View file

@ -0,0 +1,56 @@
{
"host": "127.0.0.1",
"port": 8080,
"backend": "cuda",
"device": 0,
"threads": 1,
"lazy_load": true,
"max_loaded_models": 0,
"idle_unload_ms": 0,
"min_free_memory_mb": 0,
"models": [
{
"id": "pocket-tts",
"family": "pocket_tts",
"path": "../../models/pocket-tts",
"task": "tts",
"mode": "offline",
"load_options": {
"language": "english"
},
"default_voice_preset": {
"voice_id": "alba"
},
"voice_presets": {
"cosette": {
"voice_id": "cosette"
}
}
},
{
"id": "qwen3-asr",
"family": "qwen3_asr",
"path": "../../models/Qwen3-ASR-0.6B",
"task": "asr",
"mode": "offline"
},
{
"id": "r2t2-asr",
"family": "confucius4_r2t2",
"path": "../../models/Confucius4-R2T2-GGUF/r2t2-q8_0.gguf",
"task": "asr",
"mode": "offline"
},
{
"id": "r2t2-asr-stream",
"family": "confucius4_r2t2",
"path": "../../models/Confucius4-R2T2-GGUF/r2t2-q8_0.gguf",
"task": "asr",
"mode": "streaming",
"session_options": {
"confucius4_r2t2.chunk_size_ms": "320",
"confucius4_r2t2.max_tokens": "32"
}
}
]
}

189
app/server/frontend.cpp Normal file
View file

@ -0,0 +1,189 @@
#include "frontend.h"
#include "engine/framework/debug/trace.h"
#include <stdexcept>
#include <string>
#include <utility>
namespace minitts::server {
namespace {
bool contract_value_matches(std::string_view lhs, std::string_view rhs) {
return lhs == frontend_contracts::any || rhs == frontend_contracts::any || lhs == rhs;
}
bool contract_route_overlaps(std::string_view lhs, std::string_view rhs) {
return contract_value_matches(lhs, rhs);
}
bool validate_pre_contracts(
std::string_view previous_module,
const FrontendPreContract & previous,
std::string_view next_module,
const FrontendPreContract & next) {
if (!contract_route_overlaps(previous.method, next.method) ||
!contract_route_overlaps(previous.path, next.path)) {
return false;
}
if (!contract_value_matches(previous.request_out, next.request_in)) {
throw std::runtime_error(
"incompatible frontend pre-processing order: " + std::string(previous_module) +
" outputs request state '" + std::string(previous.request_out) +
"', but " + std::string(next_module) +
" expects '" + std::string(next.request_in) + "'");
}
return true;
}
bool validate_post_contracts(
std::string_view previous_module,
const FrontendPostContract & previous,
std::string_view next_module,
const FrontendPostContract & next) {
if (!contract_route_overlaps(previous.method, next.method) ||
!contract_route_overlaps(previous.path, next.path)) {
return false;
}
if (!contract_value_matches(previous.response_out, next.response_in)) {
throw std::runtime_error(
"incompatible frontend post-processing order: " + std::string(previous_module) +
" outputs response state '" + std::string(previous.response_out) +
"', but " + std::string(next_module) +
" expects '" + std::string(next.response_in) + "'");
}
return true;
}
} // namespace
#include "server_frontend_module_declarations.inc"
std::optional<FrontendPreContract> ServerFrontendModule::pre_contract() const {
return std::nullopt;
}
std::optional<FrontendPostContract> ServerFrontendModule::post_contract() const {
return std::nullopt;
}
void ServerFrontendModule::pre_process(ServerFrontendContext & context, ServerFrontendRequest & request) {
(void) context;
(void) request;
}
void ServerFrontendModule::post_process(ServerFrontendContext & context, ServerFrontendResponse & response) {
(void) context;
(void) response;
}
void ServerFrontendRegistry::add(ServerFrontendModuleFactory factory) {
if (factory == nullptr) {
throw std::runtime_error("server frontend registration requires a module factory");
}
auto module = factory();
if (!module) {
throw std::runtime_error("server frontend module factory returned null");
}
const auto pre = module->pre_contract();
const auto post = module->post_contract();
for (auto it = modules_.rbegin(); it != modules_.rend(); ++it) {
if (pre.has_value()) {
if (const auto previous = (*it)->pre_contract()) {
if (validate_pre_contracts((*it)->name(), *previous, module->name(), *pre)) {
break;
}
}
}
}
for (auto it = modules_.rbegin(); it != modules_.rend(); ++it) {
if (post.has_value()) {
if (const auto previous = (*it)->post_contract()) {
if (validate_post_contracts((*it)->name(), *previous, module->name(), *post)) {
break;
}
}
}
}
modules_.push_back(std::move(module));
}
void ServerFrontendRegistry::add_listener(std::string name, ServerFrontendListenerFactory factory) {
if (name.empty()) {
throw std::runtime_error("server frontend listener registration requires a name");
}
if (factory == nullptr) {
throw std::runtime_error("server frontend listener registration requires a factory");
}
if (!listeners_.emplace(std::move(name), factory).second) {
throw std::runtime_error("duplicate server frontend listener registration");
}
}
bool ServerFrontendRegistry::empty() const { return modules_.empty(); }
HttpResponse ServerFrontendRegistry::handle(ServerFrontendContext & context, const HttpRequest & request) const {
ServerFrontendRequest frontend_request{request, std::nullopt};
if (engine::debug::log_enabled()) {
const auto content_type = request.headers.find("content-type");
engine::debug::log_message(
"[SERVER_FRONTEND_DEBUG] frontend.enter method=" + request.method +
" path=" + request.path +
" content_type=" +
(content_type == request.headers.end() ? std::string("<none>") : content_type->second) +
" body_bytes=" + std::to_string(request.body.size()));
}
for (const auto & module : modules_) {
if (engine::debug::log_enabled()) {
engine::debug::log_message(
"[SERVER_FRONTEND_DEBUG] frontend.pre.begin module=" + std::string(module->name()) +
" path=" + frontend_request.request.path +
" body_bytes=" + std::to_string(frontend_request.request.body.size()));
}
module->pre_process(context, frontend_request);
if (engine::debug::log_enabled()) {
const auto content_type = frontend_request.request.headers.find("content-type");
engine::debug::log_message(
"[SERVER_FRONTEND_DEBUG] frontend.pre.end module=" + std::string(module->name()) +
" path=" + frontend_request.request.path +
" content_type=" +
(content_type == frontend_request.request.headers.end() ? std::string("<none>") : content_type->second) +
" body_bytes=" + std::to_string(frontend_request.request.body.size()) +
" short_circuit=" + (frontend_request.response.has_value() ? "true" : "false"));
}
if (frontend_request.response.has_value()) {
return std::move(*frontend_request.response);
}
}
auto core_response = context.forward_to_core(frontend_request.request);
if (engine::debug::log_enabled()) {
engine::debug::log_message(
"[SERVER_FRONTEND_DEBUG] frontend.core_response status=" + std::to_string(core_response.status) +
" content_type=" + core_response.content_type +
" body_bytes=" + std::to_string(core_response.body.size()));
}
ServerFrontendResponse frontend_response{request, frontend_request.request, std::move(core_response)};
for (const auto & module : modules_) {
module->post_process(context, frontend_response);
}
return std::move(frontend_response.response);
}
std::unique_ptr<ServerFrontendListener> ServerFrontendRegistry::make_listener(std::string_view name) const {
const auto it = listeners_.find(std::string(name));
if (it == listeners_.end()) {
throw std::runtime_error("server frontend listener is not available in this build: " + std::string(name));
}
return it->second();
}
void register_static_server_frontends(ServerFrontendRegistry & registry) {
(void) registry;
#include "server_frontend_module_registrations.inc"
}
} // namespace minitts::server

105
app/server/frontend.h Normal file
View file

@ -0,0 +1,105 @@
#pragma once
#include "http.h"
#include <filesystem>
#include <functional>
#include <memory>
#include <optional>
#include <string_view>
#include <string>
#include <unordered_map>
#include <vector>
namespace minitts::server {
class ServerFrontendContext {
public:
virtual ~ServerFrontendContext() = default;
virtual HttpResponse forward_to_core(const HttpRequest & request) = 0;
virtual std::filesystem::path resolve_request_path(const std::filesystem::path & path) const = 0;
virtual std::filesystem::path make_frontend_temp_path(std::string_view filename) = 0;
};
struct ServerFrontendRequest {
HttpRequest request;
std::optional<HttpResponse> response;
};
struct ServerFrontendResponse {
const HttpRequest & original_request;
const HttpRequest & core_request;
HttpResponse response;
};
struct FrontendPreContract {
std::string_view method;
std::string_view path;
std::string_view request_in;
std::string_view request_out;
};
struct FrontendPostContract {
std::string_view method;
std::string_view path;
std::string_view response_in;
std::string_view response_out;
};
namespace frontend_contracts {
inline constexpr std::string_view any = "*";
inline constexpr std::string_view client_encoded_audio_request = "client_encoded_audio_request";
inline constexpr std::string_view core_wav_audio_request = "core_wav_audio_request";
inline constexpr std::string_view client_mp3_speech_request = "client_mp3_speech_request";
inline constexpr std::string_view core_wav_speech_request = "core_wav_speech_request";
inline constexpr std::string_view core_wav_speech_response = "core_wav_speech_response";
inline constexpr std::string_view client_mp3_speech_response = "client_mp3_speech_response";
} // namespace frontend_contracts
class ServerFrontendModule {
public:
virtual ~ServerFrontendModule() = default;
virtual std::string_view name() const = 0;
virtual std::optional<FrontendPreContract> pre_contract() const;
virtual std::optional<FrontendPostContract> post_contract() const;
virtual void pre_process(ServerFrontendContext & context, ServerFrontendRequest & request);
virtual void post_process(ServerFrontendContext & context, ServerFrontendResponse & response);
};
using ServerFrontendModuleFactory = std::unique_ptr<ServerFrontendModule> (*)();
using ServerFrontendOptions = std::unordered_map<std::string, std::string>;
class ServerFrontendListener {
public:
virtual ~ServerFrontendListener() = default;
virtual std::string_view name() const = 0;
virtual void serve(
const std::string & host,
int port,
IHttpHandler & handler,
ShutdownRequested shutdown_requested,
uint64_t max_request_body_bytes,
const ServerFrontendOptions & options) = 0;
};
using ServerFrontendListenerFactory = std::unique_ptr<ServerFrontendListener> (*)();
class ServerFrontendRegistry {
public:
void add(ServerFrontendModuleFactory factory);
void add_listener(std::string name, ServerFrontendListenerFactory factory);
bool empty() const;
HttpResponse handle(ServerFrontendContext & context, const HttpRequest & request) const;
std::unique_ptr<ServerFrontendListener> make_listener(std::string_view name) const;
private:
std::vector<std::unique_ptr<ServerFrontendModule>> modules_;
std::unordered_map<std::string, ServerFrontendListenerFactory> listeners_;
};
void register_static_server_frontends(ServerFrontendRegistry & registry);
} // namespace minitts::server

910
app/server/http.cpp Normal file
View file

@ -0,0 +1,910 @@
#include "http.h"
#include "engine/framework/debug/trace.h"
#include "engine/framework/io/json.h"
#include <algorithm>
#include <array>
#include <cctype>
#include <charconv>
#include <chrono>
#include <cstdint>
#include <cerrno>
#include <iostream>
#include <istream>
#include <limits>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <streambuf>
#include <string_view>
#include <thread>
#include <utility>
#include <vector>
#ifdef _WIN32
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
using SocketHandle = SOCKET;
constexpr SocketHandle kInvalidSocket = INVALID_SOCKET;
#else
#include <arpa/inet.h>
#include <netinet/in.h>
#include <poll.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
using SocketHandle = int;
constexpr SocketHandle kInvalidSocket = -1;
#endif
namespace minitts::server {
namespace {
std::string lower_ascii(std::string value) {
for (char & ch : value) {
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
return value;
}
std::string trim(std::string value) {
auto is_space = [](unsigned char ch) { return std::isspace(ch) != 0; };
value.erase(value.begin(), std::find_if(value.begin(), value.end(), [&](char ch) { return !is_space(ch); }));
value.erase(std::find_if(value.rbegin(), value.rend(), [&](char ch) { return !is_space(ch); }).base(), value.end());
return value;
}
std::string json_quote(std::string_view value) {
return engine::io::json::stringify_string(value);
}
const char * status_text(int status) noexcept {
switch (status) {
case 200:
return "OK";
case 204:
return "No Content";
case 400:
return "Bad Request";
case 403:
return "Forbidden";
case 404:
return "Not Found";
case 405:
return "Method Not Allowed";
case 413:
return "Payload Too Large";
case 500:
return "Internal Server Error";
case 503:
return "Service Unavailable";
default:
return "Error";
}
}
class SocketRuntime {
public:
SocketRuntime() {
#ifdef _WIN32
WSADATA data;
const int rc = WSAStartup(MAKEWORD(2, 2), &data);
if (rc != 0) {
throw std::runtime_error("WSAStartup failed: " + std::to_string(rc));
}
#endif
}
~SocketRuntime() {
#ifdef _WIN32
WSACleanup();
#endif
}
};
void close_socket(SocketHandle socket) {
if (socket == kInvalidSocket) {
return;
}
#ifdef _WIN32
closesocket(socket);
#else
close(socket);
#endif
}
class UniqueSocket {
public:
UniqueSocket() = default;
explicit UniqueSocket(SocketHandle socket)
: socket_(socket) {}
UniqueSocket(const UniqueSocket &) = delete;
UniqueSocket & operator=(const UniqueSocket &) = delete;
UniqueSocket(UniqueSocket && other) noexcept
: socket_(std::exchange(other.socket_, kInvalidSocket)) {}
UniqueSocket & operator=(UniqueSocket && other) noexcept {
if (this != &other) {
close_socket(socket_);
socket_ = std::exchange(other.socket_, kInvalidSocket);
}
return *this;
}
~UniqueSocket() {
close_socket(socket_);
}
SocketHandle get() const noexcept {
return socket_;
}
private:
SocketHandle socket_ = kInvalidSocket;
};
void send_all(SocketHandle socket, const std::string & data) {
size_t offset = 0;
while (offset < data.size()) {
const auto remaining = data.size() - offset;
#ifdef _WIN32
const int written = send(
socket,
data.data() + offset,
static_cast<int>(std::min<size_t>(remaining, std::numeric_limits<int>::max())),
0);
#else
const ssize_t written = send(socket, data.data() + offset, remaining, 0);
#endif
if (written <= 0) {
throw std::runtime_error("socket send failed");
}
offset += static_cast<size_t>(written);
}
}
// An SSE body is written while a model lock is held, so an unbounded blocking
// send() lets a client that uploads but never reads fill the kernel send buffer
// and pin that model indefinitely. A send timeout turns it into a failed write.
void set_send_timeout(SocketHandle socket, int timeout_ms) {
#ifdef _WIN32
const DWORD timeout = static_cast<DWORD>(timeout_ms);
setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast<const char *>(&timeout), sizeof(timeout));
#else
timeval timeout{};
timeout.tv_sec = timeout_ms / 1000;
timeout.tv_usec = (timeout_ms % 1000) * 1000;
setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
#endif
}
// The endpoints that consume their body incrementally. Gating on the path as
// well as the encoding keeps every other endpoint's request handling bit-for-bit
// unchanged, instead of silently altering how any chunked request is read.
constexpr std::string_view kLiveTranscriptionPath = "/v1/audio/transcriptions/live";
constexpr std::string_view kLiveSpeechPath = "/v1/audio/speech/live";
// True only when the header names exactly one transfer-coding and that coding is
// "chunked". A substring test would accept "notchunked" as well as chains like
// "gzip, chunked" — and for the latter, stripping the chunk framing would hand the
// PCM decoder compressed bytes, which it would happily interpret as audio.
bool is_chunked_only(std::string_view value) {
bool saw_coding = false;
size_t start = 0;
while (start <= value.size()) {
const size_t comma = value.find(',', start);
const auto end = comma == std::string_view::npos ? value.size() : comma;
const std::string coding = trim(std::string(value.substr(start, end - start)));
if (!coding.empty()) {
if (saw_coding || lower_ascii(coding) != "chunked") {
return false;
}
saw_coding = true;
}
if (comma == std::string_view::npos) {
break;
}
start = comma + 1;
}
return saw_coding;
}
bool wants_incremental_body(const HttpRequest & request) {
if (request.path != kLiveTranscriptionPath && request.path != kLiveSpeechPath) {
return false;
}
const auto it = request.headers.find("transfer-encoding");
if (it == request.headers.end()) {
return false;
}
return is_chunked_only(it->second);
}
// De-frames an HTTP/1.1 chunked request body off a live socket into a byte
// stream. `underflow()` blocks in recv() waiting for the next chunk, which is
// precisely the contract `AudioChunkReader` documents for a live source — so a
// streaming task can pull capture-time audio straight through it without the
// body ever being fully materialized.
//
// Bounded on four axes, because this stream is read while a model lock is held:
// an idle timeout between reads, an absolute deadline for the whole body, a cap on
// total bytes, and a cap on any single chunk. A client that opens a body and then
// stalls, never stops, or names an enormous chunk therefore cannot pin the model
// indefinitely or exhaust the host. The values come from `LiveIngestLimits`
// (app/server/http.h), which the handler resolves per request.
class ChunkedSocketStreambuf final : public std::streambuf {
public:
ChunkedSocketStreambuf(SocketHandle socket, std::string prefetched, LiveIngestLimits limits)
: socket_(socket),
pending_(std::move(prefetched)),
limits_(limits),
started_(std::chrono::steady_clock::now()),
// Bytes that arrived alongside the headers still count toward the total,
// or the cap could be overshot by one receive before it is ever consulted.
received_bytes_(pending_.size()) {}
protected:
int_type underflow() override {
if (gptr() < egptr()) {
return traits_type::to_int_type(*gptr());
}
// Covers the bytes that arrived alongside the headers. A body short enough to
// fit in that first receive never calls receive_more(), so a cap enforced only
// there would be skipped by the single request most likely to be probing it.
require_within_body_cap();
// A chunk is handed out in windows rather than all at once. Reads that stay
// inside the published get area never re-enter underflow(), so publishing a
// whole 8 MiB chunk would let the body keep advancing across many model
// iterations without the deadline ever being consulted again. Windowing puts
// a bound on how far past the deadline consumption can get.
if (publish_window()) {
return traits_type::to_int_type(*gptr());
}
if (!next_chunk()) {
return traits_type::eof();
}
return traits_type::to_int_type(*gptr());
}
private:
// poll() rather than select(): an accepted descriptor can be >= FD_SETSIZE
// once enough connections are open, and FD_SET on such a descriptor is
// undefined behaviour that corrupts the stack.
bool wait_readable() const {
int wait_ms = limits_.idle_timeout_ms;
// Clamp to whatever is left of the total deadline, so a read starting just
// before it expires cannot overshoot by a further idle period.
if (limits_.total_timeout_ms > 0) {
const auto remaining = limits_.total_timeout_ms -
static_cast<int>(std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - started_)
.count());
if (remaining <= 0) {
return false;
}
wait_ms = (wait_ms <= 0) ? remaining : std::min(wait_ms, remaining);
}
if (wait_ms <= 0) {
return true;
}
pollfd descriptor{};
descriptor.fd = socket_;
descriptor.events = POLLIN;
#ifdef _WIN32
return WSAPoll(&descriptor, 1, wait_ms) > 0;
#else
return poll(&descriptor, 1, wait_ms) > 0;
#endif
}
void require_within_body_cap() const {
if (limits_.max_body_bytes > 0 && received_bytes_ > limits_.max_body_bytes) {
throw std::runtime_error("live request body exceeded its maximum size");
}
}
// Enforced at every point where the body advances, not only before a receive:
// checking it in receive_more() alone lets an already-buffered tail — the rest
// of a large chunk, trailers, the terminating chunk — be consumed after the
// deadline has passed, which is not what "deadline" means to a caller reasoning
// about how long the model can be held.
void require_within_deadline() const {
if (limits_.total_timeout_ms <= 0) {
return;
}
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - started_)
.count();
if (elapsed >= limits_.total_timeout_ms) {
throw std::runtime_error("live request body exceeded its total deadline");
}
}
// Returns false on a clean half-close; throws when a bound is exceeded, so a
// stalled client surfaces as a stream error rather than a silent truncation
// that would look like a legitimate end of audio.
bool receive_more() {
require_within_deadline();
if (!wait_readable()) {
// The wait is clamped to whatever is left of the total deadline, so it can
// end because that expired rather than because the peer went quiet. Report
// which one it was instead of always blaming the idle timeout.
require_within_deadline();
throw std::runtime_error("live request body stalled: no data within the idle timeout");
}
std::array<char, 8192> buffer{};
#ifdef _WIN32
const int received = recv(socket_, buffer.data(), static_cast<int>(buffer.size()), 0);
#else
const ssize_t received = recv(socket_, buffer.data(), buffer.size(), 0);
#endif
// Re-checked after the wait: poll() can return readable just before the
// deadline, so without this a final receive would be processed past it.
require_within_deadline();
if (received <= 0) {
return false;
}
received_bytes_ += static_cast<size_t>(received);
require_within_body_cap();
pending_.append(buffer.data(), static_cast<size_t>(received));
return true;
}
bool ensure_available(size_t count) {
while (pending_.size() - pending_pos_ < count) {
if (!receive_more()) {
return false;
}
}
return true;
}
static constexpr size_t kMaxLineBytes = 8192;
bool read_line(std::string & line) {
for (;;) {
const auto eol = pending_.find("\r\n", pending_pos_);
if (eol != std::string::npos) {
// Checked on the found line too, not only while still searching: a
// receive can deliver the terminator along with enough bytes to put
// the line well past the cap, which would otherwise be accepted and
// make the stated bound roughly double what it claims.
if (eol - pending_pos_ > kMaxLineBytes) {
throw std::runtime_error("chunked request body: oversized chunk header");
}
line = pending_.substr(pending_pos_, eol - pending_pos_);
pending_pos_ = eol + 2;
return true;
}
if (pending_.size() - pending_pos_ > kMaxLineBytes) {
throw std::runtime_error("chunked request body: oversized chunk header");
}
if (!receive_more()) {
return false;
}
}
}
bool next_chunk() {
if (finished_) {
return false;
}
// Bounds consumption of already-buffered framing as well as waiting for more,
// so the deadline holds even for a body that arrives faster than it is read.
require_within_deadline();
std::string header;
if (!read_line(header)) {
// The peer closed without the mandatory terminating 0-chunk. Treating
// that as a clean end would hand the caller a truncated body that looks
// byte-for-byte like a complete one — for audio, a cut-off sentence
// indistinguishable from the speaker stopping. Fail instead.
throw std::runtime_error(
"chunked request body: connection closed before the terminating chunk");
}
if (const auto extension = header.find(';'); extension != std::string::npos) {
header.resize(extension); // chunk extensions are unused here
}
header = trim(header);
// Parsed by hand rather than with stoull: the size is attacker-controlled, and
// "ffffffffffffffff" would otherwise yield SIZE_MAX, whose `size + 2` wraps to
// 1 and slips past the availability check into invalid iterator arithmetic.
if (header.empty()) {
throw std::runtime_error("chunked request body: empty chunk size");
}
// Unlike the other bounds, a per-chunk cap cannot be disabled: the chunk is
// materialized in memory, so "unbounded" is not implementable. 0 falls back
// to the default rather than meaning "no limit".
const size_t chunk_cap =
limits_.max_chunk_bytes > 0 ? limits_.max_chunk_bytes : LiveIngestLimits{}.max_chunk_bytes;
size_t size = 0;
for (const char digit : header) {
int value = 0;
if (digit >= '0' && digit <= '9') {
value = digit - '0';
} else if (digit >= 'a' && digit <= 'f') {
value = digit - 'a' + 10;
} else if (digit >= 'A' && digit <= 'F') {
value = digit - 'A' + 10;
} else {
throw std::runtime_error(
"chunked request body: invalid chunk size \"" + header + "\"");
}
// Checked in two steps, each on operands already known to be in range.
// The single-expression form `size > (cap - value) / 16` underflows
// whenever cap < value — at cap = 1 a digit of 'f' wraps to a huge
// quotient and is accepted — which is the same class of bug this
// hand-rolled parse exists to prevent.
if (size > chunk_cap / 16) {
throw std::runtime_error("chunked request body: chunk size exceeds the maximum");
}
size *= 16;
// size <= (cap / 16) * 16 <= cap here, so the subtraction cannot wrap.
if (static_cast<size_t>(value) > chunk_cap - size) {
throw std::runtime_error("chunked request body: chunk size exceeds the maximum");
}
size += static_cast<size_t>(value);
}
if (size == 0) {
finished_ = true;
// Trailers are bounded: they arrive after the terminating chunk, so
// without a cap a peer could stream them indefinitely into pending_,
// which is never compacted on this path.
size_t trailer_bytes = 0;
std::string trailer;
for (;;) {
if (!read_line(trailer)) {
throw std::runtime_error(
"chunked request body: connection closed inside the trailer");
}
if (trailer.empty()) {
break;
}
trailer_bytes += trailer.size();
if (trailer_bytes > 8192) {
throw std::runtime_error("chunked request body: trailer section too large");
}
}
return false;
}
if (!ensure_available(size + 2)) {
throw std::runtime_error("chunked request body: connection closed mid-chunk");
}
// The two bytes after chunk data must be CRLF; accepting anything else lets a
// desynchronised sender's payload be silently reinterpreted as framing.
if (pending_.compare(pending_pos_ + size, 2, "\r\n") != 0) {
throw std::runtime_error("chunked request body: chunk data not terminated by CRLF");
}
// Dropped before the assign, which may reallocate and free what the get area
// still points at. publish_window() can throw on the deadline before it calls
// setg(), and leaving stale pointers across that throw would hand anyone who
// caught the exception and read again a dangling comparison.
setg(nullptr, nullptr, nullptr);
chunk_.assign(
pending_.begin() + static_cast<std::ptrdiff_t>(pending_pos_),
pending_.begin() + static_cast<std::ptrdiff_t>(pending_pos_ + size));
pending_pos_ += size + 2; // also skip the chunk's trailing CRLF
if (pending_pos_ > 64 * 1024) {
pending_.erase(0, pending_pos_);
pending_pos_ = 0;
}
chunk_pos_ = 0;
publish_window();
return true;
}
// Exposes the next slice of the current chunk, re-checking the deadline each
// time. Returns false once the chunk is spent, which is the caller's signal to
// read the next one off the socket.
bool publish_window() {
if (chunk_pos_ >= chunk_.size()) {
return false;
}
require_within_deadline();
const size_t window = std::min(kWindowBytes, chunk_.size() - chunk_pos_);
setg(chunk_.data() + chunk_pos_,
chunk_.data() + chunk_pos_,
chunk_.data() + chunk_pos_ + window);
chunk_pos_ += window;
return true;
}
static constexpr size_t kWindowBytes = 64 * 1024;
SocketHandle socket_;
std::string pending_; // received but not yet de-framed
size_t pending_pos_ = 0;
std::vector<char> chunk_; // the de-framed chunk currently being served
size_t chunk_pos_ = 0; // how much of chunk_ has been published so far
LiveIngestLimits limits_;
std::chrono::steady_clock::time_point started_;
size_t received_bytes_;
bool finished_ = false;
};
// `leftover` receives any body bytes that arrived alongside the headers, but only
// for a chunked body — that case returns with the socket deliberately undrained
// so the handler can consume the rest as it is sent.
HttpRequest read_http_request(
SocketHandle socket,
uint64_t max_request_body_bytes,
std::string & leftover) {
std::string data;
std::array<char, 8192> buffer{};
size_t header_end = std::string::npos;
while (header_end == std::string::npos) {
#ifdef _WIN32
const int received = recv(socket, buffer.data(), static_cast<int>(buffer.size()), 0);
#else
const ssize_t received = recv(socket, buffer.data(), buffer.size(), 0);
#endif
if (received <= 0) {
throw std::runtime_error("socket receive failed before HTTP headers");
}
data.append(buffer.data(), static_cast<size_t>(received));
header_end = data.find("\r\n\r\n");
if (data.size() > 1024 * 1024 && header_end == std::string::npos) {
throw std::runtime_error("HTTP headers exceed 1 MiB");
}
}
std::istringstream header_stream(data.substr(0, header_end));
HttpRequest request;
std::string line;
if (!std::getline(header_stream, line)) {
throw std::runtime_error("empty HTTP request");
}
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
std::istringstream request_line(line);
request_line >> request.method >> request.path;
if (request.method.empty() || request.path.empty()) {
throw std::runtime_error("invalid HTTP request line");
}
const auto query = request.path.find('?');
if (query != std::string::npos) {
request.query = request.path.substr(query + 1);
request.path = request.path.substr(0, query);
}
while (std::getline(header_stream, line)) {
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
const auto pos = line.find(':');
if (pos == std::string::npos) {
continue;
}
const std::string name = lower_ascii(trim(line.substr(0, pos)));
const std::string value = trim(line.substr(pos + 1));
// Repeated field lines are equivalent to one comma-separated list (RFC 9110
// 5.2), and for Transfer-Encoding that equivalence is load-bearing: splitting
// "gzip, chunked" across two lines would otherwise overwrite the first with
// the second and leave a bare "chunked" that passes the coding check. Only
// this header is combined, so no other endpoint's parsing changes.
if (name == "transfer-encoding") {
auto & slot = request.headers[name];
// Appended in place rather than rebuilt: `slot = slot + ", " + value`
// recopies everything accumulated so far on each line, so a header block
// packed with repeated fields costs quadratic time — and this runs before
// dispatch, on every endpoint, on attacker-supplied input.
if (!slot.empty()) {
slot.append(", ");
}
slot.append(value);
continue;
}
request.headers[name] = value;
}
const auto transfer_encoding_it = request.headers.find("transfer-encoding");
const bool chunked_body =
transfer_encoding_it != request.headers.end() &&
is_chunked_only(transfer_encoding_it->second);
if (engine::debug::log_enabled()) {
const auto content_length_it = request.headers.find("content-length");
engine::debug::log_message(
"[SERVER_HTTP_DEBUG] http.headers method=" + request.method +
" path=" + request.path +
" content_length=" +
(content_length_it == request.headers.end() ? std::string("<none>") : content_length_it->second) +
" transfer_encoding=" +
(transfer_encoding_it == request.headers.end() ? std::string("<none>") : transfer_encoding_it->second) +
" incremental=" + (wants_incremental_body(request) ? "true" : "false") +
" prefetched_body_bytes=" + std::to_string(data.size() - header_end - 4));
}
if (wants_incremental_body(request)) {
leftover = data.substr(header_end + 4);
if (engine::debug::log_enabled()) {
engine::debug::log_message(
"[SERVER_HTTP_DEBUG] http.body_deferred path=" + request.path +
" leftover_bytes=" + std::to_string(leftover.size()));
}
return request;
}
if (chunked_body) {
LiveIngestLimits limits;
limits.max_body_bytes = static_cast<size_t>(std::min<uint64_t>(
max_request_body_bytes,
static_cast<uint64_t>(std::numeric_limits<size_t>::max())));
ChunkedSocketStreambuf body_buffer(
socket,
data.substr(header_end + 4),
limits);
std::istream body_stream(&body_buffer);
body_stream.exceptions(std::ios::badbit);
std::array<char, 8192> chunk_buffer{};
while (body_stream) {
body_stream.read(chunk_buffer.data(), static_cast<std::streamsize>(chunk_buffer.size()));
request.body.append(chunk_buffer.data(), static_cast<size_t>(body_stream.gcount()));
}
return request;
}
size_t content_length = 0;
if (const auto it = request.headers.find("content-length"); it != request.headers.end()) {
// std::stoull throws on a non-numeric or overflowing header, and the
// body loop below grows request.body until it reaches whatever this
// says. An absurd Content-Length is therefore an unbounded in-memory
// accumulation driven by a single request.
//
const std::string & raw = it->second;
unsigned long long parsed = 0;
const auto * first = raw.data();
const auto * last = raw.data() + raw.size();
const auto [ptr, ec] = std::from_chars(first, last, parsed);
if (ec != std::errc{} || ptr != last) {
throw std::runtime_error("invalid Content-Length header");
}
if (parsed > max_request_body_bytes) {
throw std::runtime_error(
"request body exceeds the maximum of " + std::to_string(max_request_body_bytes) + " bytes");
}
if (parsed > std::numeric_limits<size_t>::max()) {
throw std::runtime_error("request body exceeds the maximum addressable size");
}
content_length = static_cast<size_t>(parsed);
}
request.body = data.substr(header_end + 4);
while (request.body.size() < content_length) {
#ifdef _WIN32
const int received = recv(socket, buffer.data(), static_cast<int>(buffer.size()), 0);
#else
const ssize_t received = recv(socket, buffer.data(), buffer.size(), 0);
#endif
if (received <= 0) {
throw std::runtime_error("socket receive failed while reading HTTP body");
}
request.body.append(buffer.data(), static_cast<size_t>(received));
}
if (request.body.size() > content_length) {
request.body.resize(content_length);
}
if (engine::debug::log_enabled()) {
engine::debug::log_message(
"[SERVER_HTTP_DEBUG] http.body_ready path=" + request.path +
" body_bytes=" + std::to_string(request.body.size()));
}
return request;
}
std::string serialize_response(const HttpResponse & response) {
std::ostringstream out;
out << "HTTP/1.1 " << response.status << " " << status_text(response.status) << "\r\n"
<< "Content-Type: " << response.content_type << "\r\n"
<< "Content-Length: " << response.body.size() << "\r\n"
<< "Connection: close\r\n";
for (const auto & [key, value] : response.headers) {
out << key << ": " << value << "\r\n";
}
out << "\r\n";
std::string header = out.str();
header += response.body;
return header;
}
std::string serialize_stream_headers(const HttpResponse & response) {
std::ostringstream out;
out << "HTTP/1.1 " << response.status << " " << status_text(response.status) << "\r\n"
<< "Content-Type: " << response.content_type << "\r\n"
<< "Transfer-Encoding: chunked\r\n"
<< "Cache-Control: no-cache\r\n"
<< "Connection: close\r\n";
for (const auto & [key, value] : response.headers) {
out << key << ": " << value << "\r\n";
}
out << "\r\n";
return out.str();
}
class ChunkedHttpStreamWriter final : public HttpStreamWriter {
public:
explicit ChunkedHttpStreamWriter(SocketHandle socket)
: socket_(socket) {}
void write(std::string_view data) override {
if (data.empty()) {
return;
}
std::ostringstream header;
header << std::hex << data.size() << "\r\n";
send_all(socket_, header.str());
send_all(socket_, std::string(data));
send_all(socket_, "\r\n");
}
void finish() {
send_all(socket_, "0\r\n\r\n");
}
private:
SocketHandle socket_;
};
UniqueSocket bind_listen_socket(const std::string & host, int port) {
UniqueSocket socket_handle(socket(AF_INET, SOCK_STREAM, 0));
if (socket_handle.get() == kInvalidSocket) {
throw std::runtime_error("could not create listen socket");
}
int yes = 1;
#ifdef _WIN32
setsockopt(socket_handle.get(), SOL_SOCKET, SO_REUSEADDR, reinterpret_cast<const char *>(&yes), sizeof(yes));
#else
setsockopt(socket_handle.get(), SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
#endif
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(port));
if (inet_pton(AF_INET, host.c_str(), &addr.sin_addr) != 1) {
throw std::runtime_error("server host must be an IPv4 address: " + host);
}
if (bind(socket_handle.get(), reinterpret_cast<sockaddr *>(&addr), sizeof(addr)) != 0) {
throw std::runtime_error("could not bind " + host + ":" + std::to_string(port));
}
if (listen(socket_handle.get(), 16) != 0) {
throw std::runtime_error("could not listen on " + host + ":" + std::to_string(port));
}
return socket_handle;
}
void handle_client(SocketHandle client, IHttpHandler & handler, uint64_t max_request_body_bytes) {
UniqueSocket socket(client);
try {
std::string leftover;
auto request = read_http_request(socket.get(), max_request_body_bytes, leftover);
const bool incremental_body = wants_incremental_body(request);
// Asked for once, before the stream exists, because the streambuf takes its
// bounds at construction. The handler resolves them from server config and
// any per-model override — the transport has no notion of models.
const LiveIngestLimits limits =
incremental_body ? handler.live_ingest_limits(request) : LiveIngestLimits{};
if (incremental_body && limits.send_timeout_ms > 0) {
// Scoped to this endpoint: it is the only one whose response is written
// while a model lock is held, so it is the only one where a client that
// stops reading can pin a model. Applying it server-wide would risk
// truncating a large ordinary response to a merely slow client.
set_send_timeout(socket.get(), limits.send_timeout_ms);
}
// Constructed unconditionally so it outlives the handler call, but only
// published on `request` when the client actually declared a chunked body.
ChunkedSocketStreambuf body_buffer(socket.get(), std::move(leftover), limits);
std::istream body_stream(&body_buffer);
// Without this, a throw from underflow() is caught by istream and turned into
// badbit. A reader that checks gcount() then sees a short read and reports a
// clean end of input, so a stall, an oversize chunk or a mid-body disconnect
// would all be delivered as a successful, silently truncated body.
body_stream.exceptions(std::ios::badbit);
if (incremental_body) {
request.body_stream = &body_stream;
}
const auto response = handler.handle(request);
if (response.stream_body) {
send_all(socket.get(), serialize_stream_headers(response));
ChunkedHttpStreamWriter writer(socket.get());
try {
response.stream_body(writer);
} catch (const std::exception & ex) {
if (response.content_type.rfind("text/event-stream", 0) == 0) {
const std::string data =
"data: {\"type\":\"error\",\"error\":{\"message\":" +
json_quote(ex.what()) +
"}}\n\n";
writer.write(data);
} else {
std::cerr << "audiocpp_server streaming response failed: " << ex.what() << "\n";
}
}
writer.finish();
} else {
send_all(socket.get(), serialize_response(response));
}
} catch (const std::exception & ex) {
if (engine::debug::log_enabled()) {
engine::debug::log_message(std::string("[SERVER_HTTP_DEBUG] http.error ") + ex.what());
}
try {
send_all(socket.get(), serialize_response(error_response(500, ex.what(), "server_error")));
} catch (const std::exception & send_error) {
std::cerr << "audiocpp_server failed to send error response: " << send_error.what() << "\n";
}
}
}
bool wait_for_client(SocketHandle socket, int timeout_ms) {
fd_set read_set;
FD_ZERO(&read_set);
FD_SET(socket, &read_set);
timeval timeout{};
timeout.tv_sec = timeout_ms / 1000;
timeout.tv_usec = (timeout_ms % 1000) * 1000;
#ifdef _WIN32
const int ready = select(0, &read_set, nullptr, nullptr, &timeout);
#else
const int ready = select(socket + 1, &read_set, nullptr, nullptr, &timeout);
#endif
if (ready < 0) {
#ifdef _WIN32
if (WSAGetLastError() == WSAEINTR) {
#else
if (errno == EINTR) {
#endif
return false;
}
throw std::runtime_error("server select failed");
}
return ready > 0 && FD_ISSET(socket, &read_set);
}
} // namespace
HttpResponse json_response(std::string body, int status) {
return HttpResponse{status, "application/json", std::move(body), {}, {}};
}
HttpResponse error_response(int status, const std::string & message, const std::string & type) {
const std::string body = std::string("{\"error\":{\"message\":") + json_quote(message) +
",\"type\":" + json_quote(type) + "}}";
return json_response(body, status);
}
void serve_http(const std::string & host, int port, IHttpHandler & handler, ShutdownRequested shutdown_requested, uint64_t max_request_body_bytes) {
SocketRuntime sockets;
auto listen_socket = bind_listen_socket(host, port);
std::cout << "audiocpp_server listening on http://" << host << ":" << port << "\n";
while (!shutdown_requested()) {
if (!wait_for_client(listen_socket.get(), 250)) {
continue;
}
sockaddr_in client_addr{};
#ifdef _WIN32
int client_len = sizeof(client_addr);
#else
socklen_t client_len = sizeof(client_addr);
#endif
const SocketHandle client = accept(
listen_socket.get(),
reinterpret_cast<sockaddr *>(&client_addr),
&client_len);
if (client == kInvalidSocket) {
#ifdef _WIN32
const int error = WSAGetLastError();
const bool transient = error == WSAEINTR || error == WSAEWOULDBLOCK;
#else
const bool transient = errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK;
#endif
if (shutdown_requested() || transient) {
continue;
}
throw std::runtime_error("accept failed");
}
std::thread(handle_client, client, std::ref(handler), max_request_body_bytes).detach();
}
std::cout << "audiocpp_server stopped\n";
}
} // namespace minitts::server

104
app/server/http.h Normal file
View file

@ -0,0 +1,104 @@
#pragma once
#include <cstdint>
#include <functional>
#include <iosfwd>
#include <string>
#include <string_view>
#include <unordered_map>
namespace minitts::server {
// Bounds on an incrementally delivered request body. Every one of them is really a
// bound on how long a single client can hold a model: this body is read while the
// model lock is held, so a peer that stalls, never stops, or names an enormous chunk
// would otherwise pin the model or exhaust the host.
//
// Uniformly, 0 disables that bound — the same convention `busy_timeout_ms` already
// uses. Disabling one is a deliberate choice for a specific deployment (an hour-long
// dictation seat behind a trusted proxy), not a default worth shipping.
struct LiveIngestLimits {
// Longest gap between two receives before the body is declared stalled.
int idle_timeout_ms = 30'000;
// Wall-clock ceiling on the whole body, however steadily it arrives.
int total_timeout_ms = 600'000;
size_t max_body_bytes = 512u * 1024u * 1024u;
// Bounds one chunk, independently of the total. A chunk is held in memory twice
// while being de-framed, so without this a single legal chunk could name a size
// large enough to exhaust the host — while holding a model lock. The one bound
// that must stay non-zero: it also backs the overflow check in the chunk-size
// parser, so a 0 would re-admit a declared size of SIZE_MAX. Config rejects it.
size_t max_chunk_bytes = 8u * 1024u * 1024u;
// SO_SNDTIMEO on the connection, so a client that stops reading the response
// cannot hold the model open indefinitely. Per send() call, not an overall
// response deadline.
int send_timeout_ms = 30'000;
};
struct HttpRequest {
std::string method;
std::string path;
std::string query; // raw query string, e.g. "model=pocket-tts" (no leading '?')
std::unordered_map<std::string, std::string> headers;
std::string body;
// Non-null only for a route that opts into an incremental body AND whose
// client declared `Transfer-Encoding: chunked`. The body is then NOT collected
// into `body` before the handler runs — reads on this stream block until the
// client sends more, and reach EOF at the terminating chunk. This is what lets a
// handler consume audio while it is still being captured; every other request,
// on every other route, still arrives fully buffered in `body`.
//
// A half-close WITHOUT that terminating chunk is an error, not EOF: a body cut
// short by a dropped connection would otherwise be indistinguishable from one
// the client finished deliberately.
//
// The stream reads directly from the connection, so it stays valid for the
// whole exchange: through `handle()` and through any `HttpResponse::stream_body`
// that call returns. A handler may therefore capture it in `stream_body`, which
// is how the live route reads audio while writing SSE back. It must not outlive
// that callback — nothing owns it once the connection is done.
//
// Reading it can throw: the stream is configured with `exceptions(badbit)` so a
// stall, a disconnect before the terminating chunk, or a malformed frame surfaces
// as an exception rather than as a silently short body.
std::istream * body_stream = nullptr;
};
struct HttpResponse {
int status = 200;
std::string content_type = "application/json";
std::string body;
std::unordered_map<std::string, std::string> headers;
std::function<void(class HttpStreamWriter &)> stream_body;
};
class HttpStreamWriter {
public:
virtual ~HttpStreamWriter() = default;
virtual void write(std::string_view data) = 0;
};
class IHttpHandler {
public:
virtual ~IHttpHandler() = default;
virtual HttpResponse handle(const HttpRequest & request) = 0;
// Bounds to apply to this request's incremental body, asked for before the body
// stream is created and therefore before `handle()` runs. The transport has no
// notion of models, so resolving a per-model override is the handler's job; the
// default is the compiled-in policy above. Called only for a request that opts
// into an incremental body.
virtual LiveIngestLimits live_ingest_limits(const HttpRequest & request) const {
(void) request;
return {};
}
};
using ShutdownRequested = bool (*)();
HttpResponse json_response(std::string body, int status = 200);
HttpResponse error_response(int status, const std::string & message, const std::string & type);
void serve_http(const std::string & host, int port, IHttpHandler & handler, ShutdownRequested shutdown_requested, uint64_t max_request_body_bytes);
} // namespace minitts::server

View file

@ -0,0 +1,21 @@
#pragma once
#include "engine/framework/runtime/session.h"
namespace minitts::server {
// On /v1/audio/speech/live, `input` is the text a text-to-speech model speaks,
// so those models need it. A model that answers or converts the live audio
// (s2s, vc, svc) takes it as an optional prompt and has its own default.
inline bool live_speech_requires_input(engine::runtime::VoiceTaskKind task) {
switch (task) {
case engine::runtime::VoiceTaskKind::SpeechToSpeech:
case engine::runtime::VoiceTaskKind::VoiceConversion:
case engine::runtime::VoiceTaskKind::Svc:
return false;
default:
return true;
}
}
} // namespace minitts::server

369
app/server/main.cpp Normal file
View file

@ -0,0 +1,369 @@
#include "config.h"
#include "http.h"
#include "runtime.h"
#include "parallel_runtime.h"
#include "parallel_http.h"
#include "../cli/args.h"
#include "../common/build_info.h"
#include "engine/framework/audio/resampling.h"
#include "engine/framework/core/backend.h"
#include "engine/framework/core/load_progress.h"
#include "engine/framework/debug/trace.h"
#include <csignal>
#include <filesystem>
#include <iostream>
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace {
volatile std::sig_atomic_t g_shutdown_requested = 0;
void request_shutdown(int) {
g_shutdown_requested = 1;
}
bool shutdown_requested() {
return g_shutdown_requested != 0;
}
std::optional<std::string> arg_value(int argc, char ** argv, const std::string & name) {
for (int i = 1; i + 1 < argc; ++i) {
if (argv[i] == name) {
return std::string(argv[i + 1]);
}
}
return std::nullopt;
}
bool has_arg(int argc, char ** argv, const std::string & name) {
for (int i = 1; i < argc; ++i) {
if (argv[i] == name) {
return true;
}
}
return false;
}
std::vector<std::string> arg_values(int argc, char ** argv, const std::string & name) {
std::vector<std::string> out;
for (int i = 1; i + 1 < argc; ++i) {
if (argv[i] == name) {
out.emplace_back(argv[i + 1]);
++i;
}
}
return out;
}
std::pair<std::string, std::string> parse_key_value_arg(const std::string & text, const std::string & name) {
const auto eq = text.find('=');
if (eq == std::string::npos || eq == 0) {
throw std::runtime_error(name + " must be key=value");
}
return {text.substr(0, eq), text.substr(eq + 1)};
}
std::filesystem::path executable_directory(const char * argv0) {
if (argv0 == nullptr || *argv0 == '\0') {
return std::filesystem::current_path();
}
std::error_code ec;
auto path = std::filesystem::absolute(std::filesystem::path(argv0), ec);
if (ec) {
return std::filesystem::current_path();
}
path = path.lexically_normal();
if (std::filesystem::is_regular_file(path, ec)) {
return path.parent_path();
}
return std::filesystem::current_path();
}
void print_help() {
std::cout
<< "audiocpp_server [--config <server.json>] [--ui] [--host <ip>] [--port <port>] [--backend <backend>]\n"
<< " [--device <id>] [--list-devices [--json]] [--threads <n>] [--busy-timeout-ms <ms>]\n"
<< " [--max-loaded-models <n>] [--idle-unload-ms <ms>] [--min-free-memory-mb <mb>]\n"
<< " [--model-spec-override <json-or-directory|embedded>] [--voice-dir <directory>]\n"
<< " [--frontend-listener <name>] [--frontend-option key=value]\n"
<< " [--log] [--log-file <path>] [--load-progress 0|1]\n"
<< " [--cors-origins <origins>] [--resample-kernel-cache-mb <mb>]\n"
<< " --version print build version, commit, compiler, platform, and enabled backends\n"
<< " --parallel-jobs opt into the experimental parallel runtime; per-model slots default to 1\n"
<< " --ui serve the embedded WebUI\n"
<< " --no-ui disable the embedded WebUI\n"
<< " --ui-management allow WebUI model management and downloads; requires\n"
<< " AUDIOCPP_BUILD_NATIVE_MODEL_MANAGER=ON at build time\n"
<< " --host <ip> server bind address; default 127.0.0.1\n"
<< " --port <port> server listening port; default 8080\n"
<< " --backend cpu|cuda|hip|rocm|vulkan|metal|webgpu default cuda (rocm is an alias for hip)\n"
<< " --backend-impl <name> optional backend implementation; default preserves existing dispatch\n"
<< " --list-devices list available backend devices and exit; add --json\n"
<< " for PCI ids and free/total memory as JSON\n"
<< " --busy-timeout-ms <ms> fail a request with 503 when the model has been\n"
<< " busy this long; default 300000, 0 disables\n"
<< " --max-loaded-models <n> keep at most n models resident in memory, unloading\n"
<< " the least recently used idle model first; 1 enforces\n"
<< " a single loaded model, default 0 (no limit)\n"
<< " --idle-unload-ms <ms> unload all resident models after this many ms without\n"
<< " any model load/run; default 0 (disabled), next request\n"
<< " reloads lazily\n"
<< " --min-free-memory-mb <mb> refuse a model load unless host and GPU each keep at\n"
<< " least this many MiB free after the load; default 0\n"
<< " (guard disabled)\n"
<< " --resample-kernel-cache-mb <mb> keep at most this many MiB of resampling kernels cached,\n"
<< " evicting the least recently used; default 32, 0 disables;\n"
<< " a kernel larger than this is rebuilt on every call\n"
<< " --voice-dir <directory> override the shared reference voice library directory\n"
<< " --frontend-listener <name> opt-in frontend transport listener, e.g. https or websocket\n"
<< " --frontend-option key=value listener-specific option; may be repeated\n"
<< " --cors-origins \"*\" experimental; disabled by default. Allows browser\n"
<< " requests from any origin for trusted local demos only\n"
<< " --load-progress 0|1 with --log/--log-file, emit model-load progress keys\n"
<< " (runtime.load.phase, <store>.weights.upload_progress,\n"
<< " server.load.progress); default 0 (no new log lines)\n"
<< "\n"
<< "Endpoints:\n"
<< " GET / embedded WebUI (enabled by default with a config)\n"
<< " GET /health\n"
<< " GET /v1/models\n"
<< " GET /v1/recipes\n"
<< " POST /v1/models/load available with --ui-management; path, or\n"
<< " package_id with the native model manager\n"
<< " POST /v1/models/unload available with --ui-management\n"
<< " POST /v1/ui/upload temporary browser upload for WebUI requests\n"
<< " POST /v1/ui/models/install background package download/preparation\n"
<< " POST /v1/ui/models/install/stop stop one active package download\n"
<< " POST /v1/ui/models/clean-partial remove abandoned package staging files\n"
<< " POST /v1/ui/models/delete remove one installed package precision\n"
<< " GET /v1/ui/models-root current and binary-local default models folders\n"
<< " POST /v1/ui/models-root select a models folder (empty path restores default)\n"
<< " POST /v1/ui/browse-directories list local folders for the native folder picker\n"
<< " GET /v1/ui/models/install-status[?id=<package>]\n"
<< " GET /v1/ui/models/package-sizes package sizes from metadata-only checks\n"
<< " GET /v1/audio/voices?model=<id>\n"
<< " POST /v1/audio/speech\n"
<< " POST /v1/audio/speech/live?model=<id>[&return_text=true]\n"
<< " raw PCM in a chunked body, speech audio deltas as SSE on the same connection;\n"
<< " return_text adds the text the model writes as text deltas\n"
<< " POST /v1/audio/transcriptions\n"
<< " fields: file, model, language, prompt, stream\n"
<< " POST /v1/batches/transcriptions\n"
<< " fields: repeated file, model, language, prompt, options\n"
<< " POST /v1/audio/alignments\n"
<< " fields: file, model, text, language\n"
<< " OpenAI-style streaming: speech stream_format=sse|audio, transcription stream=true\n"
<< " POST /v1/audio/transcriptions/live?model=<id>\n"
<< " raw PCM in a chunked body, transcript deltas as SSE on the same connection\n"
<< " POST /v1/tasks/run\n"
<< " POST /v1/tasks/batch\n"
<< " POST /v1/tasks/stream\n"
<< " events and result in one JSON body, or as SSE with stream_format=sse\n"
<< " POST /v1/recipes/run\n"
<< " run a built-in multi-model recipe; set stream=true for SSE\n";
}
} // namespace
int main(int argc, char ** argv) {
try {
if (has_arg(argc, argv, "--list-devices")) {
if (has_arg(argc, argv, "--json")) {
engine::core::print_backend_devices_json(std::cout);
} else {
engine::core::print_backend_devices(std::cout);
}
return 0;
}
if (has_arg(argc, argv, "--version")) {
minitts::app::print_build_info(std::cout);
return 0;
}
if (has_arg(argc, argv, "--help") || has_arg(argc, argv, "-h")) {
print_help();
return 0;
}
const bool parallel_jobs = has_arg(argc, argv, "--parallel-jobs");
if (parallel_jobs) { engine::io::json::enable_serialized_json_parsing(); }
const auto config_path = arg_value(argc, argv, "--config");
const bool ui_requested = has_arg(argc, argv, "--ui");
if (!config_path.has_value() && !ui_requested) {
throw std::runtime_error("missing required --config argument (or use --ui for the native WebUI)");
}
const auto log_file = arg_value(argc, argv, "--log-file");
engine::debug::configure_logging(engine::debug::LoggingConfig{
has_arg(argc, argv, "--log") || log_file.has_value(),
log_file,
});
// Opt-in model-load progress reporting (runtime.load.phase,
// <store>.weights.upload_progress, server.load.progress). Default
// off so the log for existing downstream pipelines is unchanged.
if (const auto load_progress = arg_value(argc, argv, "--load-progress")) {
if (*load_progress != "0" && *load_progress != "1") {
throw std::runtime_error("--load-progress expects 0 or 1");
}
engine::core::set_load_progress_enabled(*load_progress == "1");
}
std::signal(SIGINT, request_shutdown);
std::signal(SIGTERM, request_shutdown);
#ifdef SIGPIPE
// Writing to a socket whose peer has already disconnected (for example a
// client that closed an SSE/chunked stream early) would otherwise deliver
// SIGPIPE and terminate the whole server. Ignore it so the failed send
// surfaces as an EPIPE error on that single request thread, which
// handle_client already unwinds cleanly, instead of taking the process down.
std::signal(SIGPIPE, SIG_IGN);
#endif
auto config = config_path.has_value()
? minitts::server::load_server_config(*config_path, parallel_jobs)
: minitts::server::ServerConfig{};
if (!config_path.has_value()) {
config.lazy_load = true;
}
if (ui_requested) {
config.ui_enabled = true;
config.ui_requested = true;
}
if (has_arg(argc, argv, "--no-ui")) {
config.ui_enabled = false;
config.ui_requested = false;
}
if (has_arg(argc, argv, "--ui-management")) {
config.ui_management = true;
}
#if !defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER)
if (config.ui_management) {
throw std::runtime_error(
"UI model management is not available in this build; reconfigure with "
"-DAUDIOCPP_BUILD_NATIVE_MODEL_MANAGER=ON");
}
#endif
if (const auto host = arg_value(argc, argv, "--host")) {
config.host = *host;
}
if (const auto port = arg_value(argc, argv, "--port")) {
config.port = std::stoi(*port);
}
if (const auto cors_origins = arg_value(argc, argv, "--cors-origins")) {
config.cors_origins = *cors_origins;
}
if (const auto backend = arg_value(argc, argv, "--backend")) {
config.backend = minitts::server::parse_server_backend(*backend);
}
if (const auto impl = arg_value(argc, argv, "--backend-impl")) {
config.backend_impl = *impl;
}
if (const auto device = arg_value(argc, argv, "--device")) {
config.device = std::stoi(*device);
}
if (const auto threads = arg_value(argc, argv, "--threads")) {
config.threads = std::stoi(*threads);
}
if (const auto busy_timeout = arg_value(argc, argv, "--busy-timeout-ms")) {
config.busy_timeout_ms = std::stoi(*busy_timeout);
}
if (const auto max_loaded_models = arg_value(argc, argv, "--max-loaded-models")) {
config.max_loaded_models = std::stoi(*max_loaded_models);
}
if (const auto idle_unload_ms = arg_value(argc, argv, "--idle-unload-ms")) {
config.idle_unload_ms = std::stoi(*idle_unload_ms);
}
if (const auto min_free_memory_mb = arg_value(argc, argv, "--min-free-memory-mb")) {
config.min_free_memory_mb = std::stoi(*min_free_memory_mb);
}
if (const auto resample_kernel_cache_mb = arg_value(argc, argv, "--resample-kernel-cache-mb")) {
config.resample_kernel_cache_mb =
minitts::cli::parse_non_negative_int(*resample_kernel_cache_mb, "--resample-kernel-cache-mb");
}
if (const auto model_spec = arg_value(argc, argv, "--model-spec-override")) {
config.model_spec_override = std::filesystem::path(*model_spec);
}
if (const auto voice_dir = arg_value(argc, argv, "--voice-dir")) {
config.voice_dir = std::filesystem::path(*voice_dir);
}
if (const auto frontend_listener = arg_value(argc, argv, "--frontend-listener")) {
config.frontend_listener = *frontend_listener;
}
for (const auto & option : arg_values(argc, argv, "--frontend-option")) {
auto [key, value] = parse_key_value_arg(option, "--frontend-option");
config.frontend_options[std::move(key)] = std::move(value);
}
if (config.frontend_listener.empty() && !config.frontend_options.empty()) {
throw std::runtime_error("--frontend-option requires --frontend-listener");
}
if (!(config.cors_origins == "*" || config.cors_origins == "")) {
throw std::runtime_error("--cors-origins must be '*' (allow all origins) or '' (disabled)");
}
if (config.threads <= 0) {
throw std::runtime_error("--threads must be positive");
}
if (config.busy_timeout_ms < 0) {
throw std::runtime_error("--busy-timeout-ms must be >= 0 (0 disables the guard)");
}
if (config.max_loaded_models < 0) {
throw std::runtime_error("--max-loaded-models must be >= 0 (0 disables the limit)");
}
if (config.idle_unload_ms < 0) {
throw std::runtime_error("--idle-unload-ms must be >= 0 (0 disables idle unload)");
}
if (config.min_free_memory_mb < 0) {
throw std::runtime_error("--min-free-memory-mb must be >= 0 (0 disables the memory guard)");
}
if (config.resample_kernel_cache_mb.has_value()) {
engine::audio::set_resample_kernel_cache_budget_bytes(
static_cast<size_t>(*config.resample_kernel_cache_mb) * 1024ull * 1024ull);
}
const auto ui_resource_anchor = executable_directory(argc > 0 ? argv[0] : nullptr);
auto serve = [&](auto & state) {
if (!config.frontend_listener.empty()) {
auto listener = state.make_frontend_listener(config.frontend_listener);
listener->serve(
config.host,
config.port,
state,
shutdown_requested,
config.max_request_body_bytes,
config.frontend_options);
} else {
const auto http_listener = parallel_jobs
? minitts::server::serve_parallel_http
: minitts::server::serve_http;
http_listener(
config.host,
config.port,
state,
shutdown_requested,
config.max_request_body_bytes);
}
};
if (parallel_jobs) {
std::cerr
<< "WARNING: --parallel-jobs enables EXPERIMENTAL server behavior.\n"
<< "Validated scope: controlled CPU session/queue lifecycle tests and representative\n"
<< "CUDA/Vulkan capacity-one cold/warm inference. No catalogue multi-slot models\n"
<< "are admitted by this framework PR. Reconfiguration, unload/eviction, disconnect\n"
<< "and shutdown have partial test coverage; real concurrent GPU streams/batches,\n"
<< "Metal model execution and hung-backend cancellation remain unverified.\n"
<< "See docs/maintainers/parallel_server_behavior_contract.md for tested scopes\n"
<< "and known limitations. Busy timeout does not cancel backend execution.\n";
minitts::server::ParallelServerState state(config, std::filesystem::current_path(), ui_resource_anchor);
serve(state);
} else {
minitts::server::ServerState state(config, std::filesystem::current_path(), ui_resource_anchor);
serve(state);
}
return 0;
} catch (const std::exception & ex) {
std::cerr << "audiocpp_server failed: " << ex.what() << "\n";
return 1;
}
}

View file

@ -0,0 +1,670 @@
#include "model_installer.h"
#include "engine/framework/io/filesystem.h"
#include "engine/framework/package_manager/manager.h"
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cctype>
#include <cstdlib>
#include <fstream>
#include <map>
#include <mutex>
#include <random>
#include <sstream>
#include <stdexcept>
#include <string_view>
#include <thread>
namespace minitts::server {
namespace {
std::string json_quote(std::string_view value) {
std::string result;
result.reserve(value.size() + 2);
result.push_back('"');
for (const unsigned char ch : value) {
switch (ch) {
case '"': result += "\\\""; break;
case '\\': result += "\\\\"; break;
case '\b': result += "\\b"; break;
case '\f': result += "\\f"; break;
case '\n': result += "\\n"; break;
case '\r': result += "\\r"; break;
case '\t': result += "\\t"; break;
default:
if (ch < 0x20) {
constexpr char hex[] = "0123456789abcdef";
result += "\\u00";
result.push_back(hex[(ch >> 4) & 0xf]);
result.push_back(hex[ch & 0xf]);
} else {
result.push_back(static_cast<char>(ch));
}
}
}
result.push_back('"');
return result;
}
bool valid_package_id(const std::string & value) {
if (value.empty() || value.size() > 128) {
return false;
}
for (const unsigned char ch : value) {
if (!(std::isalnum(ch) || ch == '_' || ch == '-')) {
return false;
}
}
return true;
}
void validate_argument(const std::string & value, const char * name) {
if (value.find_first_of("\r\n") != std::string::npos) {
throw std::runtime_error(std::string(name) + " contains an invalid newline");
}
#ifdef _WIN32
if (value.find_first_of("\"&|<>^") != std::string::npos) {
throw std::runtime_error(std::string(name) + " contains an unsupported command character");
}
#endif
}
std::string shell_quote(const std::string & value) {
#ifdef _WIN32
return "\"" + value + "\"";
#else
std::string result = "'";
for (const char ch : value) {
if (ch == '\'') {
result += "'\\''";
} else {
result.push_back(ch);
}
}
return result + "'";
#endif
}
std::string python_command() {
if (const char * configured = std::getenv("AUDIOCPP_PYTHON")) {
const std::string value(configured);
validate_argument(value, "AUDIOCPP_PYTHON");
return shell_quote(value);
}
#ifdef _WIN32
return "python";
#else
return "python3";
#endif
}
std::string read_log_tail_text(const std::filesystem::path & path) {
std::ifstream input(path, std::ios::binary);
if (!input) {
return {};
}
input.seekg(0, std::ios::end);
const auto size = input.tellg();
constexpr std::streamoff kTailBytes = 65536;
if (size > kTailBytes) {
input.seekg(size - kTailBytes);
} else {
input.seekg(0);
}
std::ostringstream content;
content << input.rdbuf();
return content.str();
}
void write_text_atomic(const std::filesystem::path & path, const std::string & text) {
const auto temporary = path.string() + ".tmp";
{
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
if (!output) {
throw std::runtime_error("could not write package inventory: " + temporary);
}
output.write(text.data(), static_cast<std::streamsize>(text.size()));
}
std::error_code error;
std::filesystem::remove(path, error);
std::filesystem::rename(temporary, path);
}
struct LogSnapshot {
std::string message;
uint64_t downloaded_bytes = 0;
uint64_t total_bytes = 0;
bool has_progress = false;
};
LogSnapshot inspect_log(const std::filesystem::path & path) {
LogSnapshot snapshot;
std::istringstream lines(read_log_tail_text(path));
std::string line;
constexpr std::string_view marker = "AUDIOCPP_PROGRESS ";
while (std::getline(lines, line)) {
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
if (line.empty()) {
continue;
}
if (line.rfind(marker.data(), 0) != 0) {
snapshot.message = line;
continue;
}
uint64_t downloaded = 0;
uint64_t total = 0;
bool parsed_downloaded = false;
std::istringstream fields(line.substr(marker.size()));
std::string field;
while (fields >> field) {
const auto separator = field.find('=');
if (separator == std::string::npos) {
continue;
}
try {
const auto value = std::stoull(field.substr(separator + 1));
const auto name = field.substr(0, separator);
if (name == "downloaded") {
downloaded = value;
parsed_downloaded = true;
} else if (name == "total") {
total = value;
}
} catch (const std::exception &) {
// A partially written progress line is expected while the worker is
// appending to the log. Keep the previous complete marker.
}
}
if (parsed_downloaded) {
snapshot.downloaded_bytes = downloaded;
snapshot.total_bytes = total;
snapshot.has_progress = true;
}
}
return snapshot;
}
int64_t now_ms() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
}
std::filesystem::path make_job_root() {
const auto temporary = std::filesystem::temp_directory_path();
std::random_device random;
for (int attempt = 0; attempt < 32; ++attempt) {
const auto ticks = std::chrono::high_resolution_clock::now().time_since_epoch().count();
const auto candidate = temporary /
("audiocpp-model-installer-" + std::to_string(ticks) + "-" +
std::to_string(static_cast<unsigned long long>(random())));
std::error_code error;
if (std::filesystem::create_directory(candidate, error)) {
return candidate;
}
if (error) {
throw std::runtime_error(
"failed to create model installer temporary directory: " + error.message());
}
}
throw std::runtime_error("failed to allocate a unique model installer temporary directory");
}
} // namespace
struct ModelInstaller::State {
struct Job {
std::string package_id;
std::string state = "queued";
std::string message;
std::filesystem::path log_path;
std::filesystem::path cancel_path;
bool cancel_requested = false;
std::shared_ptr<std::atomic_bool> cancel_flag = std::make_shared<std::atomic_bool>(false);
int exit_code = -1;
int64_t started_at_ms = 0;
int64_t finished_at_ms = 0;
uint64_t downloaded_bytes = 0;
uint64_t total_bytes = 0;
};
std::filesystem::path repository_root;
std::filesystem::path models_root;
std::filesystem::path job_root;
mutable std::mutex mutex;
std::map<std::string, Job> jobs;
std::string size_state = "idle";
std::string size_message = "Package sizes have not been checked";
std::filesystem::path size_output_path;
std::filesystem::path size_error_path;
std::filesystem::path installed_output_path;
uint64_t size_generation = 0;
std::shared_ptr<engine::package_manager::PackageManager> native_manager;
~State() {
std::error_code error;
std::filesystem::remove_all(job_root, error);
}
};
ModelInstaller::ModelInstaller(
std::filesystem::path repository_root,
std::filesystem::path models_root)
: state_(std::make_shared<State>()) {
state_->repository_root = std::filesystem::absolute(std::move(repository_root)).lexically_normal();
state_->models_root = std::filesystem::absolute(std::move(models_root)).lexically_normal();
state_->job_root = make_job_root();
std::filesystem::create_directories(state_->models_root);
state_->size_output_path = state_->job_root / "package-sizes.json";
state_->size_error_path = state_->job_root / "package-sizes.log";
state_->installed_output_path = state_->job_root / "installed-packages.json";
state_->native_manager = std::make_shared<engine::package_manager::PackageManager>(
state_->repository_root, state_->models_root);
}
ModelInstaller::~ModelInstaller() = default;
bool ModelInstaller::has_active_jobs() const {
std::lock_guard<std::mutex> lock(state_->mutex);
for (const auto & [id, job] : state_->jobs) {
(void) id;
if (job.state == "queued" || job.state == "running" || job.state == "cancelling") {
return true;
}
}
return false;
}
std::string ModelInstaller::start(
const std::string & package_id,
const std::string & source_file,
const std::string & output_file,
const std::string & source_directory,
const std::string & variant,
bool overwrite) {
if (!valid_package_id(package_id)) {
throw std::runtime_error("invalid model-manager package id");
}
validate_argument(source_directory, "source directory");
validate_argument(source_file, "source file");
validate_argument(output_file, "output file");
validate_argument(variant, "variant");
const bool legacy_conversion =
!source_directory.empty() || !source_file.empty() || !output_file.empty() || !variant.empty();
// The native WebUI does not send converter inputs, so normal UI installs
// always use the C++ package manager below. This deprecated Python branch
// is retained only for old direct API callers that still pass conversion
// fields, and should not be treated as part of the UI download path.
const auto script = state_->repository_root / "tools" / "model_manager_deprecated.py";
if (legacy_conversion && !std::filesystem::is_regular_file(script)) {
throw std::runtime_error(
"model preparation helper was not found at " + script.string() +
"; run the server from an updated audio.cpp source or portable bundle root");
}
std::filesystem::path source_path;
if (!source_directory.empty()) {
source_path = std::filesystem::path(source_directory);
if (source_path.is_relative()) {
source_path = state_->repository_root / source_path;
}
source_path = std::filesystem::absolute(source_path).lexically_normal();
}
auto resolve_optional_path = [this](const std::string & value) {
if (value.empty()) {
return std::filesystem::path{};
}
auto path = std::filesystem::path(value);
if (path.is_relative()) {
path = state_->repository_root / path;
}
return std::filesystem::absolute(path).lexically_normal();
};
const auto source_file_path = resolve_optional_path(source_file);
const auto output_file_path = resolve_optional_path(output_file);
const auto log_path = state_->job_root / (package_id + ".log");
const auto cancel_path = state_->job_root / (package_id + ".cancel");
{
std::error_code error;
std::filesystem::remove(cancel_path, error);
}
{
std::lock_guard<std::mutex> lock(state_->mutex);
const auto existing = state_->jobs.find(package_id);
if (existing != state_->jobs.end() &&
(existing->second.state == "queued" || existing->second.state == "running" ||
existing->second.state == "cancelling")) {
throw std::runtime_error("installation is already running for " + package_id);
}
State::Job job;
job.package_id = package_id;
job.message = "Waiting for model preparation worker";
job.log_path = log_path;
job.cancel_path = cancel_path;
state_->jobs[package_id] = std::move(job);
}
const auto shared = state_;
std::thread([shared, package_id, source_file_path, output_file_path, source_path, variant, overwrite,
legacy_conversion, script, log_path, cancel_path]() {
try {
{
std::lock_guard<std::mutex> lock(shared->mutex);
auto & job = shared->jobs.at(package_id);
if (!job.cancel_requested) {
job.state = "running";
job.message = "Downloading and preparing model files";
}
job.started_at_ms = now_ms();
}
if (!legacy_conversion) {
std::shared_ptr<std::atomic_bool> cancel_flag;
{
std::lock_guard<std::mutex> lock(shared->mutex);
cancel_flag = shared->jobs.at(package_id).cancel_flag;
}
const auto message = shared->native_manager->install(
package_id, overwrite, cancel_flag,
[shared, package_id](const engine::package_manager::PackageProgress & progress) {
std::lock_guard<std::mutex> lock(shared->mutex);
auto & job = shared->jobs.at(package_id);
job.downloaded_bytes = progress.downloaded_bytes;
job.total_bytes = progress.total_bytes;
if (!progress.message.empty()) job.message = progress.message;
});
std::lock_guard<std::mutex> lock(shared->mutex);
auto & job = shared->jobs.at(package_id);
job.finished_at_ms = now_ms();
job.exit_code = 0;
job.state = job.cancel_requested ? "cancelled" : "complete";
job.message = job.cancel_requested
? "Download stopped; completed staging files were removed" : message;
if (job.state == "complete") {
job.downloaded_bytes = job.total_bytes;
++shared->size_generation;
shared->size_state = "idle";
shared->size_message = "Package inventory will be refreshed";
}
return;
}
std::string command = python_command() + " -u " + shell_quote(script.string()) +
" install " + shell_quote(package_id) +
" --models-root " + shell_quote(shared->models_root.string());
if (overwrite) {
command += " --overwrite";
}
if (legacy_conversion && !source_path.empty()) {
command += " --source-dir " + shell_quote(source_path.string());
}
if (legacy_conversion && !source_file_path.empty()) {
command += " --source-file " + shell_quote(source_file_path.string());
}
if (legacy_conversion && !output_file_path.empty()) {
command += " --output-file " + shell_quote(output_file_path.string());
}
if (legacy_conversion && !variant.empty()) {
command += " --variant " + shell_quote(variant);
}
command += " > " + shell_quote(log_path.string()) + " 2>&1";
const int result = std::system(command.c_str());
const auto log = inspect_log(log_path);
std::lock_guard<std::mutex> lock(shared->mutex);
auto & job = shared->jobs.at(package_id);
job.exit_code = result;
job.finished_at_ms = now_ms();
const bool cancelled = job.cancel_requested || std::filesystem::is_regular_file(cancel_path);
job.state = cancelled ? "cancelled" : result == 0 ? "complete" : "failed";
job.downloaded_bytes = log.downloaded_bytes;
job.total_bytes = log.total_bytes;
job.message = !log.message.empty()
? log.message
: (cancelled ? "Download stopped; completed staging files were removed"
: result == 0 ? "Model installation completed" : "Model installation failed");
if (result == 0) {
++shared->size_generation;
shared->size_state = "idle";
shared->size_message = "Package inventory will be refreshed";
}
} catch (const std::exception & error) {
std::lock_guard<std::mutex> lock(shared->mutex);
auto & job = shared->jobs.at(package_id);
job.state = job.cancel_requested ? "cancelled" : "failed";
job.message = job.cancel_requested
? "Download stopped; completed staging files were removed"
: error.what();
job.exit_code = -1;
job.finished_at_ms = now_ms();
}
}).detach();
return status(package_id);
}
std::string ModelInstaller::status(const std::string & package_id) const {
std::lock_guard<std::mutex> lock(state_->mutex);
auto job_json = [](const State::Job & job) {
std::string message = job.message;
uint64_t downloaded_bytes = job.downloaded_bytes;
uint64_t total_bytes = job.total_bytes;
if (job.state == "running" || job.state == "cancelling") {
const auto log = inspect_log(job.log_path);
if (!log.message.empty()) {
message = log.message;
}
if (log.has_progress) {
downloaded_bytes = log.downloaded_bytes;
total_bytes = log.total_bytes;
}
}
int progress_percent = -1;
if (job.state == "queued") {
progress_percent = 0;
} else if (job.state == "complete") {
progress_percent = 100;
} else if (total_bytes > 0) {
progress_percent = static_cast<int>(std::min<uint64_t>(100, downloaded_bytes * 100 / total_bytes));
}
return std::string("{\"id\":") + json_quote(job.package_id) +
",\"state\":" + json_quote(job.state) +
",\"message\":" + json_quote(message) +
",\"exit_code\":" + std::to_string(job.exit_code) +
",\"downloaded_bytes\":" + std::to_string(downloaded_bytes) +
",\"total_bytes\":" + std::to_string(total_bytes) +
",\"progress_percent\":" + std::to_string(progress_percent) +
",\"started_at_ms\":" + std::to_string(job.started_at_ms) +
",\"finished_at_ms\":" + std::to_string(job.finished_at_ms) + "}";
};
if (!package_id.empty()) {
const auto found = state_->jobs.find(package_id);
if (found == state_->jobs.end()) {
return "{\"id\":" + json_quote(package_id) +
",\"state\":\"idle\",\"message\":\"Not started\",\"exit_code\":-1,"
"\"downloaded_bytes\":0,\"total_bytes\":0,\"progress_percent\":-1,"
"\"started_at_ms\":0,\"finished_at_ms\":0}";
}
return job_json(found->second);
}
std::string result = "{\"data\":[";
bool first = true;
for (const auto & item : state_->jobs) {
if (!first) {
result += ",";
}
first = false;
result += job_json(item.second);
}
return result + "]}";
}
std::string ModelInstaller::stop(const std::string & package_id) {
if (!valid_package_id(package_id)) {
throw std::runtime_error("invalid model-manager package id");
}
{
std::lock_guard<std::mutex> lock(state_->mutex);
const auto found = state_->jobs.find(package_id);
if (found == state_->jobs.end()) {
throw std::runtime_error("no installation job exists for " + package_id);
}
auto & job = found->second;
if (job.state != "queued" && job.state != "running" && job.state != "cancelling") {
throw std::runtime_error("installation is not running for " + package_id);
}
std::ofstream marker(job.cancel_path, std::ios::binary | std::ios::trunc);
if (!marker) {
throw std::runtime_error("could not create download cancellation marker");
}
marker << "cancel\n";
job.cancel_requested = true;
job.cancel_flag->store(true);
job.state = "cancelling";
job.message = "Stopping download and removing its staging files";
}
return status(package_id);
}
std::string ModelInstaller::clean_partial(const std::string & package_id) {
if (!valid_package_id(package_id)) {
throw std::runtime_error("invalid model-manager package id");
}
std::filesystem::path cancel_path;
{
std::lock_guard<std::mutex> lock(state_->mutex);
const auto found = state_->jobs.find(package_id);
if (found != state_->jobs.end() &&
(found->second.state == "queued" || found->second.state == "running" ||
found->second.state == "cancelling")) {
throw std::runtime_error("stop the active installation before cleaning partial files for " + package_id);
}
if (found != state_->jobs.end()) {
cancel_path = found->second.cancel_path;
}
}
const auto message = state_->native_manager->clean_partial(package_id);
if (!cancel_path.empty()) {
std::error_code error;
std::filesystem::remove(cancel_path, error);
}
{
std::lock_guard<std::mutex> lock(state_->mutex);
state_->jobs.erase(package_id);
}
return "{\"id\":" + json_quote(package_id) +
",\"cleaned\":true,\"message\":" +
json_quote(message.empty() ? "Partial download files cleaned" : message) + "}";
}
std::optional<engine::package_manager::PackageInfo> ModelInstaller::package_info(
const std::string & package_id) const {
if (!valid_package_id(package_id)) {
return std::nullopt;
}
return state_->native_manager->package_info(package_id);
}
std::string ModelInstaller::remove(const std::string & package_id) {
if (!valid_package_id(package_id)) {
throw std::runtime_error("invalid model-manager package id");
}
{
std::lock_guard<std::mutex> lock(state_->mutex);
const auto job = state_->jobs.find(package_id);
if (job != state_->jobs.end() &&
(job->second.state == "queued" || job->second.state == "running" ||
job->second.state == "cancelling")) {
throw std::runtime_error("installation is still running for " + package_id);
}
}
const auto message = state_->native_manager->remove(package_id);
{
std::lock_guard<std::mutex> lock(state_->mutex);
state_->jobs.erase(package_id);
++state_->size_generation;
state_->size_state = "idle";
state_->size_message = "Package inventory will be refreshed";
}
return "{\"id\":" + json_quote(package_id) +
",\"removed\":true,\"message\":" +
json_quote(message.empty() ? "Model package removed" : message) + "}";
}
std::string ModelInstaller::package_sizes() {
bool start_scan = false;
uint64_t scan_generation = 0;
std::filesystem::path size_output_path;
std::filesystem::path size_error_path;
std::filesystem::path installed_output_path;
{
std::lock_guard<std::mutex> lock(state_->mutex);
if (state_->size_state == "idle") {
scan_generation = ++state_->size_generation;
const auto suffix = std::to_string(scan_generation);
state_->size_output_path = state_->job_root / ("package-sizes-" + suffix + ".json");
state_->size_error_path = state_->job_root / ("package-sizes-" + suffix + ".log");
state_->installed_output_path = state_->job_root / ("installed-packages-" + suffix + ".json");
size_output_path = state_->size_output_path;
size_error_path = state_->size_error_path;
installed_output_path = state_->installed_output_path;
state_->size_state = "running";
state_->size_message = "Checking package sizes";
start_scan = true;
std::ofstream(state_->size_output_path, std::ios::trunc);
std::ofstream(state_->size_error_path, std::ios::trunc);
write_text_atomic(state_->installed_output_path, state_->native_manager->inventory(false));
}
}
if (start_scan) {
const auto shared = state_;
std::thread([shared, scan_generation, size_output_path, size_error_path,
installed_output_path]() {
try {
const auto output = shared->native_manager->inventory(true);
write_text_atomic(size_output_path, output);
std::lock_guard<std::mutex> lock(shared->mutex);
if (shared->size_generation != scan_generation) {
return;
}
shared->size_state = "complete";
shared->size_message = "Package sizes are ready";
} catch (const std::exception & error) {
std::lock_guard<std::mutex> lock(shared->mutex);
if (shared->size_generation == scan_generation) {
shared->size_state = "failed";
shared->size_message = error.what();
}
}
}).detach();
}
std::lock_guard<std::mutex> lock(state_->mutex);
std::string data = "[]";
if (state_->size_state == "complete" || state_->size_state == "running") {
const auto & source = state_->size_state == "complete"
? state_->size_output_path
: state_->installed_output_path;
data = engine::io::read_text_file(source);
while (!data.empty() && std::isspace(static_cast<unsigned char>(data.back()))) {
data.pop_back();
}
if (data.empty() || data.front() != '[' || data.back() != ']') {
data = "[]";
}
}
return "{\"state\":" + json_quote(state_->size_state) +
",\"message\":" + json_quote(state_->size_message) +
",\"data\":" + data + "}";
}
} // namespace minitts::server

View file

@ -0,0 +1,44 @@
#pragma once
#include "engine/framework/package_manager/manager.h"
#include <filesystem>
#include <memory>
#include <optional>
#include <string>
namespace minitts::server {
// Runs model package work outside the request thread. Normal package downloads,
// inventory, version checks, cancellation, cleanup, and uninstall are native C++.
// Requests with explicit converter inputs retain the deprecated Python helper
// until those checkpoint-conversion workflows migrate to native tools.
class ModelInstaller {
public:
ModelInstaller(std::filesystem::path repository_root, std::filesystem::path models_root);
~ModelInstaller();
ModelInstaller(const ModelInstaller &) = delete;
ModelInstaller & operator=(const ModelInstaller &) = delete;
std::string start(
const std::string & package_id,
const std::string & source_file,
const std::string & output_file,
const std::string & source_directory,
const std::string & variant,
bool overwrite);
std::string status(const std::string & package_id = {}) const;
std::string package_sizes();
std::string stop(const std::string & package_id);
std::string clean_partial(const std::string & package_id);
std::string remove(const std::string & package_id);
std::optional<engine::package_manager::PackageInfo> package_info(const std::string & package_id) const;
bool has_active_jobs() const;
private:
struct State;
std::shared_ptr<State> state_;
};
} // namespace minitts::server

119
app/server/model_memory.cpp Normal file
View file

@ -0,0 +1,119 @@
#include "model_memory.h"
#include "engine/framework/assets/tensor_source.h"
#include "engine/framework/runtime/registry.h"
#include <filesystem>
#include <functional>
#include <set>
#include <string>
#include <system_error>
#include <vector>
namespace minitts::server {
std::optional<size_t> estimate_model_memory_bytes(const ServerModelConfig & model) {
size_t weights = 0;
std::error_code ec;
// A compiled engine can be declared by a session option and also sit inside a checkpoint tree
// that gets summed, so each path is counted once.
std::set<std::filesystem::path> counted;
// Checkpoint trees (safetensors / HF-style directories) are summed recursively
// with hard limits so a pathological tree cannot stall the load path or blow the
// counter; anything beyond the limits contributes 0, while the fixed floor
// below keeps the estimate conservative even for a directory that reads as
// empty.
constexpr size_t kMaxDepth = 3;
constexpr size_t kMaxFiles = 10000;
size_t visited_files = 0;
const auto add_file = [&](const std::filesystem::path & path) {
if (!std::filesystem::is_regular_file(path, ec)) {
return;
}
if (!counted.insert(std::filesystem::weakly_canonical(path, ec)).second) {
return;
}
// A file that disappears or becomes unreadable mid-scan contributes 0
// (file_size reports (uintmax_t)-1 with ec set) rather than wrapping the
// counter.
const auto size = std::filesystem::file_size(path, ec);
if (ec) {
return;
}
weights += static_cast<size_t>(size);
++visited_files;
};
const std::function<void(const std::filesystem::path &, size_t)> add_tree =
[&](const std::filesystem::path & path, size_t depth) {
if (std::filesystem::is_regular_file(path, ec)) {
add_file(path);
} else if (std::filesystem::is_directory(path, ec) && depth < kMaxDepth) {
std::filesystem::directory_iterator it(path, ec), end;
for (; it != end && visited_files < kMaxFiles; it.increment(ec)) {
add_tree(it->path(), depth + 1);
}
}
};
// The model reports final residency; its resource files must not be counted again as weights.
const auto resources = engine::runtime::make_default_registry().model_resources(
model.family, model.path, model.session_options);
for (const auto & requirement : resources.engines) {
counted.insert(std::filesystem::weakly_canonical(requirement.path));
}
// Estimate only what the loader will actually read from model.path, so the
// guard neither overestimates nor masks the loader's own error:
// - a single-file model contributes that file;
// - a model directory contributes the one GGUF it selects (model.gguf, or the
// sole *.gguf) -- a package holding several variants is loaded from just one;
// - a directory with no GGUF is a safetensors/HF checkpoint whose whole tree
// loads, so it is summed;
// - a directory with several GGUFs and no model.gguf is ambiguous: the loader
// rejects the spec-driven case with its own "contains N GGUF files" error
// and loads family-specific layouts instead, so the footprint is
// indeterminate and the caller skips the guard rather than answer 503.
if (std::filesystem::is_regular_file(model.path, ec)) {
add_file(model.path);
} else if (std::filesystem::is_directory(model.path, ec)) {
// One listing of the directory, mirroring the loader's own selection:
// model.gguf wins, the sole *.gguf is used alone, and several GGUFs
// without model.gguf are ambiguous.
const auto ggufs = engine::assets::directory_gguf_files(model.path);
std::optional<std::filesystem::path> selected;
for (const auto & gguf : ggufs) {
if (gguf.filename() == "model.gguf") {
selected = gguf;
break;
}
}
if (!selected.has_value() && ggufs.size() == 1) {
selected = ggufs.front();
}
if (selected.has_value()) {
add_file(*selected);
} else if (!ggufs.empty()) {
return std::nullopt;
} else {
add_tree(model.path, 0);
}
}
// Relative auxiliary paths resolve against the model directory when model.path
// is a directory, and against the model file's parent when it is a file.
const std::filesystem::path aux_base =
std::filesystem::is_directory(model.path, ec) ? model.path : model.path.parent_path();
for (const auto & option : model.session_options) {
// Only file-valued options contribute; opaque values are not interpreted here.
std::filesystem::path aux(option.second);
if (aux.is_relative()) {
aux = aux_base / aux;
}
add_tree(aux, 0);
}
// Weights plus a runtime factor for Metal/GPU buffers, activation graphs and
// KV state, plus a fixed floor for per-model bookkeeping.
constexpr double kRuntimeOverheadFactor = 1.5;
constexpr size_t kFixedOverhead = 128ull * 1024 * 1024;
return static_cast<size_t>(static_cast<double>(weights) * kRuntimeOverheadFactor) + kFixedOverhead +
resources.resident_bytes;
}
} // namespace minitts::server

24
app/server/model_memory.h Normal file
View file

@ -0,0 +1,24 @@
#pragma once
#include "config.h"
#include <cstddef>
#include <optional>
namespace minitts::server {
// Estimated resident bytes a model will occupy once loaded (weights plus a
// runtime overhead factor for GPU buffers / compute graphs). A family that
// declares auxiliary resources reports their paths and final resident bytes
// through its loader. The server excludes those paths from weight scans and adds
// the reported bytes without interpreting execution plans or residency policies.
// Returns nullopt
// when the footprint is indeterminate: a model directory holding several GGUFs
// and no model.gguf. The guard must not refuse such a load with a 503 -- the
// loader either rejects the ambiguous directory itself (the spec-driven path
// fails with its "contains N GGUF files" error) or accepts it under a
// family-specific layout the estimator cannot resolve, so the guard skips
// rather than guess.
std::optional<size_t> estimate_model_memory_bytes(const ServerModelConfig & model);
} // namespace minitts::server

237
app/server/model_slots.h Normal file
View file

@ -0,0 +1,237 @@
#pragma once
#include "busy_guard.h"
#include "engine/framework/runtime/parallel_session.h"
#include <algorithm>
#include <array>
#include <condition_variable>
#include <deque>
#include <memory>
#include <utility>
namespace minitts::server {
// Requests and management share one admission queue. A manager is a barrier
// for callers behind it, never for older requests. Each waiter owns its wakeup.
class ModelSlots {
struct Waiter {
bool management;
bool granted = false;
bool expired = false;
int slot = -1;
std::optional<std::chrono::steady_clock::time_point> deadline;
std::condition_variable cv;
};
public:
class Lock {
public:
Lock(ModelSlots & owner, int slot) : owner_(&owner), slot_(slot) {}
Lock(const Lock &) = delete;
Lock & operator=(const Lock &) = delete;
Lock(Lock && other) noexcept : owner_(std::exchange(other.owner_, nullptr)), slot_(other.slot_) {}
Lock & operator=(Lock && other) noexcept {
if (this != &other) { release(); owner_ = std::exchange(other.owner_, nullptr); slot_ = other.slot_; }
return *this;
}
~Lock() { release(); }
size_t slot() const {
if (!owner_ || slot_ < 0) { throw std::logic_error("management lease has no request slot"); }
return static_cast<size_t>(slot_);
}
private:
void release() {
if (!owner_) { return; }
std::lock_guard<std::mutex> lock(owner_->mutex_);
owner_->release_slot(slot_);
owner_ = nullptr;
}
ModelSlots * owner_;
int slot_;
};
// Reserve a management barrier now and drain unrelated ready models before
// waiting for this one. Destruction cancels or releases the reservation.
class Pending {
public:
Pending(const Pending &) = delete;
Pending & operator=(const Pending &) = delete;
Pending(Pending && other) noexcept
: owner_(std::exchange(other.owner_, nullptr)), waiter_(std::move(other.waiter_)), label_(std::move(other.label_)) {}
Pending & operator=(Pending && other) noexcept {
if (this != &other) {
cancel(); owner_ = std::exchange(other.owner_, nullptr);
waiter_ = std::move(other.waiter_); label_ = std::move(other.label_);
}
return *this;
}
~Pending() { cancel(); }
std::optional<Lock> try_acquire() {
if (!owner_) { throw std::logic_error("admission already consumed"); }
std::lock_guard<std::mutex> lock(owner_->mutex_);
if (!waiter_->granted) { return std::nullopt; }
return take();
}
Lock acquire() {
if (!owner_) { throw std::logic_error("admission already consumed"); }
std::unique_lock<std::mutex> lock(owner_->mutex_);
const auto ready = [&] { return waiter_->granted || waiter_->expired; };
if (waiter_->deadline) { waiter_->cv.wait_until(lock, *waiter_->deadline, ready); }
else { waiter_->cv.wait(lock, ready); }
if (!waiter_->granted) {
owner_->cancel_waiter(waiter_);
owner_ = nullptr;
throw ServerBusyError("model '" + label_ + "': timed out waiting for a slot");
}
return take();
}
private:
friend class ModelSlots;
Pending(std::shared_ptr<Waiter> waiter, std::string_view label)
: waiter_(std::move(waiter)), label_(label) {}
Lock take() {
auto * owner = std::exchange(owner_, nullptr);
return Lock(*owner, waiter_->slot);
}
void cancel() {
if (!owner_) { return; }
std::lock_guard<std::mutex> lock(owner_->mutex_);
owner_->cancel_waiter(waiter_);
owner_ = nullptr;
}
ModelSlots * owner_ = nullptr;
std::shared_ptr<Waiter> waiter_;
std::string label_;
};
struct State { int slots; int active; int waiting_requests; int waiting_management; };
State state() const {
std::lock_guard<std::mutex> lock(mutex_);
int count = 0;
for (auto since : since_) { if (since) { ++count; } }
return {count_, count, waiting_requests_, waiting_exclusive_};
}
int active() const { return state().active; }
// Called only before publication or under an exclusive lease.
void configure(int count) {
if (count < 1 || count > static_cast<int>(engine::runtime::kMaxParallelSessions)) {
throw std::runtime_error("slots must be between 1 and 16");
}
std::lock_guard<std::mutex> lock(mutex_);
for (auto since : since_) {
if (since) { throw std::logic_error("cannot resize occupied slots"); }
}
count_ = count;
}
Lock acquire_run(int timeout_ms, std::string_view label) {
std::unique_lock<std::mutex> lock(mutex_);
const int slot = free_slot();
if (!exclusive_ && queue_.empty() && slot >= 0) {
since_[slot] = steady_now_ms();
return Lock(*this, slot);
}
const auto now = steady_now_ms();
if (model_run_has_overrun(exclusive_since_, now, timeout_ms)) {
throw ServerBusyError("model '" + std::string(label) + "': exclusive operation exceeded busy_timeout_ms");
}
bool any_active = false, all_overrun = !exclusive_;
for (int i = 0; i < count_; ++i) {
if (!since_[i]) { continue; }
any_active = true;
all_overrun = all_overrun && model_run_has_overrun(since_[i], now, timeout_ms);
}
if (any_active && all_overrun) { throw ServerBusyError("model '" + std::string(label) + "': all active slots exceeded busy_timeout_ms"); }
auto pending = enqueue(false, timeout_ms, label);
lock.unlock();
return pending.acquire();
}
Pending queue_management(int timeout_ms, std::string_view label) {
std::lock_guard<std::mutex> lock(mutex_);
const auto now = steady_now_ms();
if (model_run_has_overrun(exclusive_since_, now, timeout_ms)) {
throw ServerBusyError("model '" + std::string(label) + "': exclusive operation exceeded busy_timeout_ms");
}
for (auto since : since_) {
if (model_run_has_overrun(since, now, timeout_ms)) {
throw ServerBusyError("model '" + std::string(label) + "': active slot exceeded busy_timeout_ms");
}
}
return enqueue(true, timeout_ms, label);
}
Lock acquire(int timeout_ms, std::string_view label) { return queue_management(timeout_ms, label).acquire(); }
std::optional<Lock> try_acquire() {
std::lock_guard<std::mutex> lock(mutex_);
if (!idle() || !queue_.empty()) { return std::nullopt; }
exclusive_ = true;
exclusive_since_ = steady_now_ms();
return Lock(*this, -1);
}
private:
int free_slot() const {
for (int i = 0; i < count_; ++i) { if (!since_[i]) { return i; } }
return -1;
}
bool idle() const {
if (exclusive_) { return false; }
for (auto since : since_) { if (since) { return false; } }
return true;
}
Pending enqueue(bool management, int timeout_ms, std::string_view label) {
auto waiter = std::make_shared<Waiter>();
waiter->management = management;
if (timeout_ms > 0) { waiter->deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); }
// Construct labels before queue publication, but only arm cancellation
// after push_back succeeds: its failure must not lock this mutex again.
Pending pending(waiter, label);
queue_.push_back(waiter);
pending.owner_ = this;
if (management) { ++waiting_exclusive_; } else { ++waiting_requests_; }
dispatch();
return pending;
}
void remove_front() {
if (queue_.front()->management) { --waiting_exclusive_; } else { --waiting_requests_; }
queue_.pop_front();
}
void dispatch() {
while (!queue_.empty()) {
auto waiter = queue_.front();
if (waiter->deadline && std::chrono::steady_clock::now() >= *waiter->deadline) {
waiter->expired = true; remove_front(); waiter->cv.notify_one(); continue;
}
if (exclusive_) { return; }
if (waiter->management) {
if (!idle()) { return; }
exclusive_ = true; exclusive_since_ = steady_now_ms();
} else {
waiter->slot = free_slot();
if (waiter->slot < 0) { return; }
since_[waiter->slot] = steady_now_ms();
}
waiter->granted = true;
remove_front(); waiter->cv.notify_one();
}
}
void release_slot(int slot) {
if (slot < 0) { exclusive_ = false; exclusive_since_ = 0; }
else { since_[slot] = 0; }
dispatch();
}
void cancel_waiter(const std::shared_ptr<Waiter> & waiter) {
if (waiter->granted) { release_slot(waiter->slot); return; }
const auto it = std::find(queue_.begin(), queue_.end(), waiter);
if (it != queue_.end()) {
if (waiter->management) { --waiting_exclusive_; } else { --waiting_requests_; }
queue_.erase(it);
}
dispatch();
}
mutable std::mutex mutex_;
std::deque<std::shared_ptr<Waiter>> queue_;
std::array<int64_t, engine::runtime::kMaxParallelSessions> since_{};
int count_ = 1;
int waiting_requests_ = 0;
int waiting_exclusive_ = 0;
bool exclusive_ = false;
int64_t exclusive_since_ = 0;
};
} // namespace minitts::server

159
app/server/multipart.cpp Normal file
View file

@ -0,0 +1,159 @@
#include "multipart.h"
#include <algorithm>
#include <cctype>
#include <sstream>
namespace minitts::server {
namespace {
std::string lower_ascii(std::string value) {
for (char & ch : value) {
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
return value;
}
std::string trim(std::string value) {
auto is_space = [](unsigned char ch) { return std::isspace(ch) != 0; };
value.erase(value.begin(), std::find_if(value.begin(), value.end(), [&](char ch) { return !is_space(ch); }));
value.erase(std::find_if(value.rbegin(), value.rend(), [&](char ch) { return !is_space(ch); }).base(), value.end());
return value;
}
// Extracts a `key="value"` or `key=value` parameter from a header value such as
// `form-data; name="file"; filename="clip.wav"`.
std::string extract_header_param(const std::string & header_value, const std::string & key) {
const std::string needle = key + "=";
const std::string lowered = lower_ascii(header_value);
size_t pos = 0;
while (true) {
pos = lowered.find(needle, pos);
if (pos == std::string::npos) {
return {};
}
// Make sure we matched a whole parameter name, not a suffix (e.g. "filename" vs "name").
if (pos == 0 || header_value[pos - 1] == ' ' || header_value[pos - 1] == ';') {
break;
}
pos += needle.size();
}
size_t start = pos + needle.size();
if (start < header_value.size() && header_value[start] == '"') {
const size_t end = header_value.find('"', start + 1);
if (end == std::string::npos) {
return header_value.substr(start + 1);
}
return header_value.substr(start + 1, end - start - 1);
}
size_t end = header_value.find(';', start);
if (end == std::string::npos) {
end = header_value.size();
}
return trim(header_value.substr(start, end - start));
}
std::string strip_trailing_newline(std::string value) {
if (value.size() >= 2 && value.compare(value.size() - 2, 2, "\r\n") == 0) {
value.resize(value.size() - 2);
} else if (!value.empty() && value.back() == '\n') {
value.resize(value.size() - 1);
}
return value;
}
MultipartPart parse_part(const std::string & segment) {
MultipartPart part;
size_t header_end = segment.find("\r\n\r\n");
size_t separator_len = 4;
if (header_end == std::string::npos) {
header_end = segment.find("\n\n");
separator_len = 2;
}
if (header_end == std::string::npos) {
return part;
}
part.data = segment.substr(header_end + separator_len);
std::istringstream header_stream(segment.substr(0, header_end));
std::string line;
while (std::getline(header_stream, line)) {
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
const auto colon = line.find(':');
if (colon == std::string::npos) {
continue;
}
const std::string key = lower_ascii(trim(line.substr(0, colon)));
const std::string value = trim(line.substr(colon + 1));
if (key == "content-disposition") {
part.name = extract_header_param(value, "name");
part.filename = extract_header_param(value, "filename");
}
}
return part;
}
} // namespace
std::optional<std::string> extract_multipart_boundary(const std::string & content_type) {
const std::string lowered = lower_ascii(content_type);
if (lowered.rfind("multipart/", 0) != 0) {
return std::nullopt;
}
const std::string key = "boundary=";
const auto pos = lowered.find(key);
if (pos == std::string::npos) {
return std::nullopt;
}
std::string value = content_type.substr(pos + key.size());
const auto semicolon = value.find(';');
if (semicolon != std::string::npos) {
value = value.substr(0, semicolon);
}
value = trim(value);
if (value.size() >= 2 && value.front() == '"' && value.back() == '"') {
value = value.substr(1, value.size() - 2);
}
if (value.empty()) {
return std::nullopt;
}
return value;
}
std::vector<MultipartPart> parse_multipart_body(const std::string & body, const std::string & boundary) {
std::vector<MultipartPart> parts;
const std::string delimiter = "--" + boundary;
size_t pos = body.find(delimiter);
if (pos == std::string::npos) {
return parts;
}
pos += delimiter.size();
while (true) {
if (body.compare(pos, 2, "--") == 0) {
break;
}
if (body.compare(pos, 2, "\r\n") == 0) {
pos += 2;
} else if (pos < body.size() && body[pos] == '\n') {
pos += 1;
}
const size_t next_delim = body.find(delimiter, pos);
if (next_delim == std::string::npos) {
break;
}
const std::string segment = strip_trailing_newline(body.substr(pos, next_delim - pos));
parts.push_back(parse_part(segment));
pos = next_delim + delimiter.size();
}
return parts;
}
} // namespace minitts::server

24
app/server/multipart.h Normal file
View file

@ -0,0 +1,24 @@
#pragma once
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
namespace minitts::server {
// One part of a multipart/form-data body, e.g. a form field or an uploaded file.
struct MultipartPart {
std::string name; // Content-Disposition "name" parameter.
std::string filename; // Content-Disposition "filename" parameter; empty for plain fields.
std::string data; // Raw part payload.
};
// Returns the boundary token from a "Content-Type: multipart/form-data; boundary=..." header,
// or std::nullopt if the header does not describe a multipart body.
std::optional<std::string> extract_multipart_boundary(const std::string & content_type);
// Splits a multipart/form-data body into its constituent parts using the given boundary.
std::vector<MultipartPart> parse_multipart_body(const std::string & body, const std::string & boundary);
} // namespace minitts::server

1038
app/server/parallel_http.cpp Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,8 @@
#pragma once
#include "http.h"
namespace minitts::server {
// Selected only by --parallel-jobs. Returns after all HTTP workers/callbacks
// have finished; shutdown interrupts socket I/O but cannot preempt GPU work.
void serve_parallel_http(const std::string & host, int port, IHttpHandler & handler,
ShutdownRequested shutdown_requested, uint64_t max_request_body_bytes);
} // namespace minitts::server

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,289 @@
#pragma once
#include "model_slots.h"
#include "config.h"
#include "frontend.h"
#include "http.h"
#include "server_protocol.h"
#if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER)
#include "model_installer.h"
#endif
#include "../streaming/streaming.h"
#include "engine/framework/io/json.h"
#include "engine/framework/runtime/model.h"
#include "engine/framework/runtime/registry.h"
#include "engine/framework/runtime/session.h"
#include "engine/framework/runtime/session_pool.h"
#include <atomic>
#include <cstdint>
#include <filesystem>
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <shared_mutex>
#include <thread>
#include <unordered_map>
#include <vector>
namespace minitts::server {
class ParallelServerState final : public IHttpHandler, public ServerFrontendContext {
public:
ParallelServerState(
ServerConfig config,
std::filesystem::path request_base,
std::filesystem::path ui_resource_anchor = {});
~ParallelServerState() override;
HttpResponse handle(const HttpRequest & request) override;
HttpResponse forward_to_core(const HttpRequest & request) override;
std::filesystem::path resolve_request_path(const std::filesystem::path & path) const override;
std::filesystem::path make_frontend_temp_path(std::string_view filename) override;
std::unique_ptr<ServerFrontendListener> make_frontend_listener(std::string_view name) const;
// Server-level `live_ingest` policy with this request's model override applied.
// Deliberately does not reject an unknown or non-streaming model: it runs before
// the handler, and rejecting here would turn a client's mistake into a dropped
// connection instead of the 400 handle_transcription_live already produces.
LiveIngestLimits live_ingest_limits(const HttpRequest & request) const override;
private:
friend struct RequestDispatcher;
struct LoadedModel {
ServerModelConfig config;
// Stable registration identity, independent of configuration replacement.
std::string registered_id;
engine::runtime::TaskSpec task;
std::unique_ptr<engine::runtime::ILoadedVoiceModel> model;
std::atomic<bool> parallel_resident{false};
std::unique_ptr<engine::runtime::VoiceTaskSessionPool> sessions;
// Zero means unknown (not loaded); status reads this without touching
// session pointers that may be concurrently unloaded.
std::atomic<size_t> parallel_capacity{0};
std::atomic<bool> loaded{false};
// Steady-clock ms of the most recent load or run of this model. Orders
// eviction when max_loaded_models forces an unload: the least recently
// used idle model goes first.
std::atomic<std::int64_t> last_used_ms{0};
mutable std::shared_mutex metadata_mutex;
std::unordered_map<std::string, RuntimeVoicePreset> voice_presets;
std::optional<RuntimeVoicePreset> default_voice_preset;
// Whether this model's contract accepts the `reference_text` request
// option, resolved once at registration (refresh_model_option_flags).
// Resolving it per request re-reads the model file's embedded spec on
// the request thread, which costs ~0.9 s per request for large GGUFs.
// `true` mirrors model_accepts_request_option's no-contract behavior.
bool accepts_reference_text = true;
// Same treatment for `language`. Clients send the field on every
// transcription whether or not the user chose one, so a model whose
// contract omits it would reject the whole request over an option
// nobody set. Resolved once at registration for the same cost reason.
bool accepts_language = true;
bool accepts_speed = true;
bool accepts_speaking_rate = true;
// The spec's request options as a JSON array, served by
// `GET /v1/models?include_params=true`. Resolved at registration for the
// same cost reason as the flags above; "[]" when the spec declares none.
std::string request_params_json = "[]";
// Leases isolated sessions to requests; management requires all slots idle.
ModelSlots busy;
std::mutex initialization_mutex;
// Release the loaded model and session from memory (frees VRAM on GPU backends).
// The next request will trigger a reload via ensure_model_loaded_locked().
bool resident() const noexcept {
return parallel_resident.load();
}
engine::runtime::IVoiceTaskSession & leased_session(size_t slot) {
return sessions->at(slot);
}
template<class Interface> Interface * leased_interface(size_t slot) {
return dynamic_cast<Interface *>(&leased_session(slot));
}
void unload();
};
// Only parallel requests bind a lease before reading configuration/preparing
// inputs. The shared lease also owns deferred stream/batch callbacks.
using RequestLease = std::shared_ptr<ModelSlots::Lock>;
RequestLease bind_request(LoadedModel & model, std::optional<int> timeout);
void unload_registered_models(const std::vector<std::pair<std::string, LoadedModel *>> & registered,
std::vector<std::string> & unloaded);
// Controlled runtime tests supply loaders and barriers without product flags.
friend class ServerRuntimeTestAccess;
std::function<engine::runtime::ModelRegistry()> registry_factory_;
// Acquire the model's run guard. `request_timeout_ms` is the caller-supplied
// override, clamped by this model's configured ceiling. Throws ServerBusyError
// (-> HTTP 503) once the effective timeout has elapsed.
ModelSlots::Lock acquire_model_run(LoadedModel & model, std::optional<int> request_timeout_ms, bool exclusive = false);
// Server policy for this model: its own busy_timeout_ms if set, else the
// top-level config value.
engine::runtime::RunMode model_run_mode(const LoadedModel & model) const;
void load_models();
HttpResponse handle_request(const HttpRequest & request, bool use_frontends);
std::unique_ptr<LoadedModel> make_model(ServerModelConfig config);
// Recompute the per-model, config-derived request-option flags (currently
// accepts_reference_text). Called at registration and on reconfiguration.
void refresh_model_option_flags(LoadedModel & model);
// Under the existing exclusive lease; reject unsupported replacement slots
// before unloading or committing configuration.
void validate_replacement_slots(const LoadedModel & existing, LoadedModel & candidate);
HttpResponse handle_health() const;
std::filesystem::path resolve_ui_model_path(const std::filesystem::path & path) const;
HttpResponse handle_model_load(const std::string & body_text);
HttpResponse handle_model_unload(const std::string & body_text);
HttpResponse handle_path_status(const std::string & body_text) const;
HttpResponse handle_ui_upload(const HttpRequest & request);
#if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER)
HttpResponse handle_package_model_load(const std::string & body_text);
HttpResponse handle_model_install(const std::string & body_text);
HttpResponse handle_model_install_stop(const std::string & body_text);
HttpResponse handle_model_clean_partial(const std::string & body_text);
HttpResponse handle_model_remove(const std::string & body_text);
HttpResponse handle_model_install_status(const HttpRequest & request) const;
HttpResponse handle_model_package_sizes();
HttpResponse handle_models_root_get() const;
HttpResponse handle_models_root_set(const std::string & body_text);
HttpResponse handle_directory_browser(const std::string & body_text) const;
#endif
HttpResponse handle_ui_asset() const;
HttpResponse handle_ui_voice_preview(const HttpRequest & request) const;
RuntimeVoicePreset load_runtime_voice_preset(const ServerModelConfig::VoicePreset & preset) const;
void load_voice_presets(LoadedModel & model) const;
void ensure_model_loaded_locked(LoadedModel & model);
// With max_loaded_models set, unload least recently used idle models until
// `loading` fits within the limit. A model mid-inference is never a victim;
// when nothing can be evicted this throws ServerBusyError (-> HTTP 503).
void evict_for_model_limit(const LoadedModel & loading);
// Refuse the load with InsufficientMemoryError (-> HTTP 503) when the
// estimated footprint plus configured headroom does not fit the free host
// memory and (for GPU backends) the backend device memory.
void ensure_model_fits_memory(const ServerModelConfig & model);
LoadedModel & require_model(const engine::io::json::Value & body);
engine::runtime::TaskRequest build_speech_request(
const LoadedModel & model,
const engine::io::json::Value & body) const;
struct TimedTaskResult;
// `busy_timeout_ms` on each of these is the per-request override parsed from the
// request body; nullopt means "use the model's configured ceiling".
TimedTaskResult run_model(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
std::optional<int> busy_timeout_ms = std::nullopt,
RequestLease request_lease = {});
TimedTaskResult run_streaming_model(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
const std::function<void(const engine::runtime::StreamEvent &)> & event_sink = {},
std::optional<int> busy_timeout_ms = std::nullopt,
RequestLease request_lease = {},
bool include_artifacts = false);
// Shared body of the two entry points above/below; `audio` selects the source.
TimedTaskResult run_streaming_model_impl(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
const minitts::app::AudioChunkStream * audio,
const std::function<void(const engine::runtime::StreamEvent &)> & event_sink,
std::optional<int> busy_timeout_ms,
RequestLease request_lease = {},
bool include_artifacts = false);
// Same as run_streaming_model, but pulls audio from `audio` instead of
// `request.audio_input`, so the samples are never fully materialized.
TimedTaskResult run_streaming_model_from(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
const minitts::app::AudioChunkStream & audio,
const std::function<void(const engine::runtime::StreamEvent &)> & event_sink = {},
std::optional<int> busy_timeout_ms = std::nullopt,
RequestLease request_lease = {});
HttpResponse handle_speech(const std::string & body_text);
HttpResponse handle_speech_stream(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
const engine::io::json::Value & body,
RequestLease request_lease = {});
HttpResponse handle_speech_live(const HttpRequest & request);
// detail selects the /v1/audio/transcriptions/details response, which adds the
// segment, speaker-turn and word arrays the plain route drops.
HttpResponse handle_transcription(const HttpRequest & request, bool detail = false);
HttpResponse handle_transcription_json(const std::string & body_text, bool detail = false);
HttpResponse handle_transcription_multipart(
const std::string & body_text, const std::string & boundary, bool detail = false);
HttpResponse handle_batch_transcriptions(const HttpRequest & request);
HttpResponse handle_batch_transcriptions_multipart(
const std::string & body_text, const std::string & boundary);
HttpResponse run_transcription(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
std::optional<int> busy_timeout_ms = std::nullopt,
bool detail = false,
RequestLease request_lease = {});
HttpResponse run_transcription_stream(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
std::optional<int> busy_timeout_ms = std::nullopt,
RequestLease request_lease = {});
HttpResponse handle_alignment(const HttpRequest & request);
HttpResponse handle_alignment_multipart(const std::string & body_text, const std::string & boundary);
HttpResponse run_alignment(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
std::optional<int> busy_timeout_ms = std::nullopt,
RequestLease request_lease = {});
HttpResponse handle_transcription_live(const HttpRequest & request);
HttpResponse handle_generic_run(const std::string & body_text);
HttpResponse handle_generic_batch(const std::string & body_text);
HttpResponse handle_generic_stream(const std::string & body_text);
HttpResponse handle_voices(const HttpRequest & request) const;
HttpResponse handle_unload_models(const std::string & body_text);
HttpResponse handle_unload_all_models();
// Background loop started when idle_unload_ms > 0: when the server has gone
// that long without a model load/run, unloads every resident (non-busy) model.
void idle_unload_loop();
void unload_idle_models();
std::string models_json(bool include_session_options = false, bool include_params = false) const;
std::string get_allowed_origin(const HttpRequest & request) const;
ServerConfig config_;
std::filesystem::path request_base_;
std::vector<std::unique_ptr<LoadedModel>> models_;
std::unordered_map<std::string, size_t> model_index_;
// The server config's model definitions by id, kept for WebUI loads of those ids.
std::unordered_map<std::string, ServerModelConfig> configured_models_;
mutable std::mutex models_mutex_;
// Serializes framework loads while max_loaded_models or the memory guard
// (min_free_memory_mb) is active, so two concurrent lazy loads cannot both
// pass the eviction/memory check and overshoot. Not taken when both are off:
// unrelated first loads stay concurrent there.
std::mutex model_load_mutex_;
// Publish a bulk operation's per-model barriers as one short transaction.
// Released before any waiting, unloading, or inference.
std::mutex bulk_admission_mutex_;
std::filesystem::path upload_root_;
std::mutex upload_root_mutex_;
std::filesystem::path repository_root_;
#if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER)
std::filesystem::path default_models_root_;
std::filesystem::path models_root_;
mutable std::mutex model_installer_mutex_;
std::unique_ptr<ModelInstaller> model_installer_;
#endif
std::atomic<uint64_t> next_upload_id_{1};
// Steady-clock ms of the most recent model load/run completion; drives idle
// unload. Updated at run start and again at completion so a long inference
// does not read as idle the moment it finishes.
std::atomic<std::int64_t> last_activity_ms_{0};
std::atomic<bool> idle_unload_shutdown_{false};
std::thread idle_unload_thread_;
ServerFrontendRegistry frontends_;
};
} // namespace minitts::server

View file

@ -0,0 +1,199 @@
#pragma once
#include "busy_guard.h"
#include "model_memory.h"
#include "server_protocol.h"
#include "engine/framework/debug/trace.h"
#include "engine/framework/runtime/errors.h"
#include <functional>
#include <optional>
#include <string>
#include <utility>
namespace minitts::server {
// Shares HTTP protocol dispatch without coupling the legacy and parallel
// runtime state or lifecycle implementations.
struct RequestDispatcher {
template <typename State>
static HttpResponse handle(State &state, const HttpRequest &request,
bool use_frontends,
const std::function<std::optional<HttpResponse>(const HttpRequest &)> &
additional_routes = {}) {
HttpResponse response;
const std::string allowed_origin = state.get_allowed_origin(request);
try {
log_request_body_if_enabled(state.config_, request);
if (request.method == "OPTIONS" &&
(!allowed_origin.empty() || state.config_.ui_enabled)) {
response.status = 204;
response.content_type = "text/plain";
response.headers["Access-Control-Allow-Headers"] = "*";
response.headers["Access-Control-Allow-Methods"] = "GET, POST";
} else if (use_frontends && !state.frontends_.empty()) {
response = state.frontends_.handle(state, request);
} else if (request.method == "GET" &&
(request.path == "/" || request.path == "/index.html")) {
response = state.handle_ui_asset();
} else if (request.method == "GET" && state.config_.ui_enabled &&
(request.path == "/sw.js" ||
request.path == "/service-worker.js")) {
response = ui_service_worker_retirement_response();
} else if (request.method == "GET" && request.path == "/favicon.ico") {
response.status = 204;
response.content_type = "image/x-icon";
} else if (request.method == "GET" && request.path == "/health") {
response = state.handle_health();
} else if (request.method == "GET" && request.path == "/v1/models") {
const auto include_session_options =
query_param(request.query, "include_session_options");
const auto include_params =
query_param(request.query, "include_params");
response = json_response(state.models_json(
include_session_options == "true" || include_session_options == "1",
include_params == "true" || include_params == "1"));
} else if (request.method == "GET" &&
request.path == "/v1/audio/voices") {
response = state.handle_voices(request);
} else if (request.method == "GET" &&
request.path == "/v1/ui/voice-preview") {
response = state.handle_ui_voice_preview(request);
} else if (request.method == "POST" &&
request.path == "/v1/models/load") {
#if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER)
response = state.handle_package_model_load(request.body);
#else
response = state.handle_model_load(request.body);
#endif
} else if (request.method == "POST" &&
request.path == "/v1/models/unload") {
response = state.handle_model_unload(request.body);
} else if (request.method == "POST" &&
request.path == "/v1/ui/path-status") {
response = state.handle_path_status(request.body);
} else if (request.method == "POST" && request.path == "/v1/ui/upload") {
response = state.handle_ui_upload(request);
}
#if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER)
else if (request.method == "GET" &&
request.path == "/v1/ui/models-root") {
response = state.handle_models_root_get();
} else if (request.method == "POST" &&
request.path == "/v1/ui/models-root") {
response = state.handle_models_root_set(request.body);
} else if (request.method == "POST" &&
request.path == "/v1/ui/browse-directories") {
response = state.handle_directory_browser(request.body);
} else if (request.method == "POST" &&
request.path == "/v1/ui/models/install") {
response = state.handle_model_install(request.body);
} else if (request.method == "POST" &&
request.path == "/v1/ui/models/install/stop") {
response = state.handle_model_install_stop(request.body);
} else if (request.method == "POST" &&
request.path == "/v1/ui/models/clean-partial") {
response = state.handle_model_clean_partial(request.body);
} else if (request.method == "POST" &&
request.path == "/v1/ui/models/delete") {
response = state.handle_model_remove(request.body);
} else if (request.method == "GET" &&
request.path == "/v1/ui/models/install-status") {
response = state.handle_model_install_status(request);
} else if (request.method == "GET" &&
request.path == "/v1/ui/models/package-sizes") {
response = state.handle_model_package_sizes();
}
#endif
else if (request.method == "POST" && request.path == "/v1/audio/speech") {
response = state.handle_speech(request.body);
} else if (request.method == "POST" &&
request.path == "/v1/audio/speech/live") {
response = state.handle_speech_live(request);
} else if (request.method == "POST" &&
request.path == "/v1/audio/transcriptions") {
response = state.handle_transcription(request);
}
// Same request shape as the route above, opt-in richer response: the
// models that align words or separate speakers report them through fields
// the transcription response drops. A separate path rather than a flag
// keeps the existing response schema fixed for every client already built
// against it.
else if (request.method == "POST" &&
request.path == "/v1/audio/transcriptions/details") {
response = state.handle_transcription(request, /*detail=*/true);
} else if (request.method == "POST" &&
request.path == "/v1/batches/transcriptions") {
response = state.handle_batch_transcriptions(request);
} else if (request.method == "POST" &&
request.path == "/v1/audio/alignments") {
response = state.handle_alignment(request);
}
// Separate path rather than a flag on the endpoint above: the input
// transport differs (raw chunked PCM vs a complete upload), so keeping it
// distinct leaves every existing transcription client untouched.
else if (request.method == "POST" &&
request.path == "/v1/audio/transcriptions/live") {
response = state.handle_transcription_live(request);
} else if (request.method == "POST" && request.path == "/v1/tasks/run") {
response = state.handle_generic_run(request.body);
} else if (request.method == "POST" &&
request.path == "/v1/tasks/batch") {
response = state.handle_generic_batch(request.body);
} else if (request.method == "POST" &&
request.path == "/v1/tasks/stream") {
response = state.handle_generic_stream(request.body);
} else if (request.method == "POST" &&
request.path == "/v1/tasks/unload_models") {
response = state.handle_unload_models(request.body);
} else if (request.method == "POST" &&
request.path == "/v1/tasks/unload_all_models") {
response = state.handle_unload_all_models();
} else {
auto additional = additional_routes ? additional_routes(request) : std::nullopt;
if (additional) {
response = std::move(*additional);
} else {
response = error_response(404, "unknown endpoint: " + request.path,
"not_found");
}
}
} catch (const engine::runtime::CapacityError &ex) {
// The request is too big for the device, which is the caller's to fix --
// reporting it as 500 sends them looking for a server fault that is not
// there. Checked before ServerBusyError only because both are
// runtime_error; the two conditions are disjoint.
response = error_response(400, ex.what(), "invalid_request_error");
} catch (const engine::runtime::InvalidRequestError &ex) {
// A request turned away as sent, such as an input the model does not
// take: the caller's to fix as well.
response = error_response(400, ex.what(), "invalid_request_error");
} catch (const InsufficientMemoryError &ex) {
response = error_response(503, ex.what(), "insufficient_memory");
} catch (const ServerBusyError &ex) {
// Non-streaming requests surface the busy state as 503 before any
// response is sent. (Streaming requests acquire the lock inside the
// stream body, after headers are sent, so there it becomes a stream error
// event instead.)
response = error_response(503, ex.what(), "server_busy");
} catch (const std::exception &ex) {
if (allowed_origin.empty()) {
throw;
}
// Keep the transport's error response, but attach CORS before returning
// it.
if (engine::debug::log_enabled()) {
engine::debug::log_message(
std::string("[SERVER_HTTP_DEBUG] http.error ") + ex.what());
}
response = error_response(500, ex.what(), "server_error");
}
if (!allowed_origin.empty()) {
response.headers["Access-Control-Allow-Origin"] = allowed_origin;
}
return response;
}
};
} // namespace minitts::server

2661
app/server/runtime.cpp Normal file

File diff suppressed because it is too large Load diff

257
app/server/runtime.h Normal file
View file

@ -0,0 +1,257 @@
#pragma once
#include "busy_guard.h"
#include "config.h"
#include "frontend.h"
#include "http.h"
#include "server_protocol.h"
#if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER)
#include "model_installer.h"
#endif
#include "../streaming/streaming.h"
#include "../recipe/recipe.h"
#include "engine/framework/io/json.h"
#include "engine/framework/runtime/model.h"
#include "engine/framework/runtime/session.h"
#include <atomic>
#include <cstdint>
#include <filesystem>
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <shared_mutex>
#include <thread>
#include <unordered_map>
#include <vector>
namespace minitts::server {
class ServerState final : public IHttpHandler, public ServerFrontendContext {
public:
ServerState(
ServerConfig config,
std::filesystem::path request_base,
std::filesystem::path ui_resource_anchor = {});
~ServerState() override;
HttpResponse handle(const HttpRequest & request) override;
HttpResponse forward_to_core(const HttpRequest & request) override;
std::filesystem::path resolve_request_path(const std::filesystem::path & path) const override;
std::filesystem::path make_frontend_temp_path(std::string_view filename) override;
std::unique_ptr<ServerFrontendListener> make_frontend_listener(std::string_view name) const;
// Server-level `live_ingest` policy with this request's model override applied.
// Deliberately does not reject an unknown or non-streaming model: it runs before
// the handler, and rejecting here would turn a client's mistake into a dropped
// connection instead of the 400 handle_transcription_live already produces.
LiveIngestLimits live_ingest_limits(const HttpRequest & request) const override;
private:
friend struct RequestDispatcher;
struct LoadedModel {
ServerModelConfig config;
engine::runtime::TaskSpec task;
std::unique_ptr<engine::runtime::ILoadedVoiceModel> model;
std::unique_ptr<engine::runtime::IVoiceTaskSession> session;
engine::runtime::IOfflineVoiceTaskSession * offline = nullptr;
engine::runtime::IStreamingVoiceTaskSession * streaming = nullptr;
std::atomic<bool> loaded{false};
// Steady-clock ms of the most recent load or run of this model. Orders
// eviction when max_loaded_models forces an unload: the least recently
// used idle model goes first.
std::atomic<std::int64_t> last_used_ms{0};
mutable std::shared_mutex metadata_mutex;
std::unordered_map<std::string, RuntimeVoicePreset> voice_presets;
std::optional<RuntimeVoicePreset> default_voice_preset;
// Whether this model's contract accepts the `reference_text` request
// option, resolved once at registration (refresh_model_option_flags).
// Resolving it per request re-reads the model file's embedded spec on
// the request thread, which costs ~0.9 s per request for large GGUFs.
// `true` mirrors model_accepts_request_option's no-contract behavior.
bool accepts_reference_text = true;
// Same treatment for `language`. Clients send the field on every
// transcription whether or not the user chose one, so a model whose
// contract omits it would reject the whole request over an option
// nobody set. Resolved once at registration for the same cost reason.
bool accepts_language = true;
bool accepts_speed = true;
bool accepts_speaking_rate = true;
// The spec's request options as a JSON array, served by
// `GET /v1/models?include_params=true`. Resolved at registration for the
// same cost reason as the flags above; "[]" when the spec declares none.
std::string request_params_json = "[]";
// Serializes runs on this model and bounds how long a caller waits for its
// turn; see BusyGuard.
BusyGuard busy;
// Release the loaded model and session from memory (frees VRAM on GPU backends).
// The next request will trigger a reload via ensure_model_loaded_locked().
void unload();
};
// Acquire the model's run guard. `request_timeout_ms` is the caller-supplied
// override, clamped by this model's configured ceiling. Throws ServerBusyError
// (-> HTTP 503) once the effective timeout has elapsed.
BusyGuard::Lock acquire_model_run(LoadedModel & model, std::optional<int> request_timeout_ms);
// Server policy for this model: its own busy_timeout_ms if set, else the
// top-level config value.
engine::runtime::RunMode model_run_mode(const LoadedModel & model) const;
void load_models();
HttpResponse handle_request(const HttpRequest & request, bool use_frontends);
std::unique_ptr<LoadedModel> make_model(ServerModelConfig config);
// Recompute the per-model, config-derived request-option flags (currently
// accepts_reference_text). Called at registration and on reconfiguration.
void refresh_model_option_flags(LoadedModel & model);
HttpResponse handle_health() const;
std::filesystem::path resolve_ui_model_path(const std::filesystem::path & path) const;
HttpResponse handle_model_load(const std::string & body_text);
HttpResponse handle_model_unload(const std::string & body_text);
HttpResponse handle_path_status(const std::string & body_text) const;
HttpResponse handle_ui_upload(const HttpRequest & request);
#if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER)
HttpResponse handle_package_model_load(const std::string & body_text);
HttpResponse handle_model_install(const std::string & body_text);
HttpResponse handle_model_install_stop(const std::string & body_text);
HttpResponse handle_model_clean_partial(const std::string & body_text);
HttpResponse handle_model_remove(const std::string & body_text);
HttpResponse handle_model_install_status(const HttpRequest & request) const;
HttpResponse handle_model_package_sizes();
HttpResponse handle_models_root_get() const;
HttpResponse handle_models_root_set(const std::string & body_text);
HttpResponse handle_directory_browser(const std::string & body_text) const;
#endif
HttpResponse handle_ui_asset() const;
HttpResponse handle_ui_voice_preview(const HttpRequest & request) const;
RuntimeVoicePreset load_runtime_voice_preset(const ServerModelConfig::VoicePreset & preset) const;
void load_voice_presets(LoadedModel & model) const;
void ensure_model_loaded_locked(LoadedModel & model);
// With max_loaded_models set, unload least recently used idle models until
// `loading` fits within the limit. A model mid-inference is never a victim;
// when nothing can be evicted this throws ServerBusyError (-> HTTP 503).
void evict_for_model_limit(const LoadedModel & loading);
// Refuse the load with InsufficientMemoryError (-> HTTP 503) when the
// estimated footprint plus configured headroom does not fit the free host
// memory and (for GPU backends) the backend device memory.
void ensure_model_fits_memory(const ServerModelConfig & model);
LoadedModel & require_model(const engine::io::json::Value & body);
engine::runtime::TaskRequest build_speech_request(
const LoadedModel & model,
const engine::io::json::Value & body) const;
struct TimedTaskResult;
// `busy_timeout_ms` on each of these is the per-request override parsed from the
// request body; nullopt means "use the model's configured ceiling".
TimedTaskResult run_model(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
std::optional<int> busy_timeout_ms = std::nullopt);
TimedTaskResult run_streaming_model(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
const std::function<void(const engine::runtime::StreamEvent &)> & event_sink = {},
std::optional<int> busy_timeout_ms = std::nullopt,
bool include_artifacts = false);
// Shared body of the two entry points above/below; `audio` selects the source.
TimedTaskResult run_streaming_model_impl(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
const minitts::app::AudioChunkStream * audio,
const std::function<void(const engine::runtime::StreamEvent &)> & event_sink,
std::optional<int> busy_timeout_ms,
bool include_artifacts = false);
// Same as run_streaming_model, but pulls audio from `audio` instead of
// `request.audio_input`, so the samples are never fully materialized.
TimedTaskResult run_streaming_model_from(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
const minitts::app::AudioChunkStream & audio,
const std::function<void(const engine::runtime::StreamEvent &)> & event_sink = {},
std::optional<int> busy_timeout_ms = std::nullopt);
HttpResponse handle_speech(const std::string & body_text);
HttpResponse handle_speech_stream(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
const engine::io::json::Value & body);
HttpResponse handle_speech_live(const HttpRequest & request);
// detail selects the /v1/audio/transcriptions/details response, which adds the
// segment, speaker-turn and word arrays the plain route drops.
HttpResponse handle_transcription(const HttpRequest & request, bool detail = false);
HttpResponse handle_transcription_json(const std::string & body_text, bool detail = false);
HttpResponse handle_transcription_multipart(
const std::string & body_text, const std::string & boundary, bool detail = false);
HttpResponse handle_batch_transcriptions(const HttpRequest & request);
HttpResponse handle_batch_transcriptions_multipart(
const std::string & body_text, const std::string & boundary);
HttpResponse run_transcription(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
std::optional<int> busy_timeout_ms = std::nullopt,
bool detail = false);
HttpResponse run_transcription_stream(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
std::optional<int> busy_timeout_ms = std::nullopt);
HttpResponse handle_alignment(const HttpRequest & request);
HttpResponse handle_alignment_multipart(const std::string & body_text, const std::string & boundary);
HttpResponse run_alignment(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
std::optional<int> busy_timeout_ms = std::nullopt);
HttpResponse handle_transcription_live(const HttpRequest & request);
HttpResponse handle_generic_run(const std::string & body_text);
HttpResponse handle_generic_batch(const std::string & body_text);
HttpResponse handle_generic_stream(const std::string & body_text);
HttpResponse handle_recipe_run(const std::string & body_text);
TimedTaskResult run_recipe(
const engine::io::json::Value & body,
const minitts::app::RecipeEventSink & event_sink = {});
std::string recipes_json() const;
HttpResponse handle_voices(const HttpRequest & request) const;
HttpResponse handle_unload_models(const std::string & body_text);
HttpResponse handle_unload_all_models();
// Background loop started when idle_unload_ms > 0: when the server has gone
// that long without a model load/run, unloads every resident (non-busy) model.
void idle_unload_loop();
void unload_idle_models();
std::string models_json(bool include_session_options = false, bool include_params = false) const;
std::string get_allowed_origin(const HttpRequest & request) const;
ServerConfig config_;
std::filesystem::path request_base_;
std::vector<std::unique_ptr<LoadedModel>> models_;
std::unordered_map<std::string, size_t> model_index_;
// The server config's model definitions by id, kept for WebUI loads of those ids.
std::unordered_map<std::string, ServerModelConfig> configured_models_;
mutable std::mutex models_mutex_;
// Serializes framework loads while max_loaded_models or the memory guard
// (min_free_memory_mb) is active, so two concurrent lazy loads cannot both
// pass the eviction/memory check and overshoot. Not taken when both are off:
// unrelated first loads stay concurrent there.
std::mutex model_load_mutex_;
std::filesystem::path upload_root_;
std::mutex upload_root_mutex_;
std::filesystem::path repository_root_;
#if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER)
std::filesystem::path default_models_root_;
std::filesystem::path models_root_;
mutable std::mutex model_installer_mutex_;
std::unique_ptr<ModelInstaller> model_installer_;
#endif
std::atomic<uint64_t> next_upload_id_{1};
// Steady-clock ms of the most recent model load/run completion; drives idle
// unload. Updated at run start and again at completion so a long inference
// does not read as idle the moment it finishes.
std::atomic<std::int64_t> last_activity_ms_{0};
std::atomic<bool> idle_unload_shutdown_{false};
std::thread idle_unload_thread_;
ServerFrontendRegistry frontends_;
minitts::app::RecipeRegistry recipes_;
};
} // namespace minitts::server

View file

@ -0,0 +1,181 @@
option(AUDIOCPP_BUILD_SERVER_FRONTENDS
"Build optional in-process audiocpp_server frontend adapters"
OFF)
set(AUDIOCPP_SERVER_FRONTEND_MODULES ""
CACHE STRING "Semicolon-separated optional audiocpp_server frontend modules to build")
set(AUDIOCPP_SERVER_FRONTENDS_DIR ""
CACHE PATH "Optional external server frontend module package")
if (NOT AUDIOCPP_BUILD_SERVER_FRONTENDS AND AUDIOCPP_SERVER_FRONTEND_MODULES)
message(FATAL_ERROR
"AUDIOCPP_SERVER_FRONTEND_MODULES requires AUDIOCPP_BUILD_SERVER_FRONTENDS=ON")
endif()
function(audiocpp_register_server_frontend AUDIOCPP_SERVER_FRONTEND_NAME)
set(options)
set(one_value_args REGISTER_FUNCTION)
set(multi_value_args SOURCES INCLUDE_DIRS LIBRARIES COMPILE_DEFINITIONS)
cmake_parse_arguments(
AUDIOCPP_REGISTERED_FRONTEND
"${options}"
"${one_value_args}"
"${multi_value_args}"
${ARGN})
set_property(GLOBAL APPEND PROPERTY
"AUDIOCPP_SERVER_FRONTEND_REGISTERED_MODULES"
"${AUDIOCPP_SERVER_FRONTEND_NAME}")
set_property(GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_NAME}_REGISTER_FUNCTION"
"${AUDIOCPP_REGISTERED_FRONTEND_REGISTER_FUNCTION}")
set_property(GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_NAME}_SOURCES"
"${AUDIOCPP_REGISTERED_FRONTEND_SOURCES}")
set_property(GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_NAME}_INCLUDE_DIRS"
"${AUDIOCPP_REGISTERED_FRONTEND_INCLUDE_DIRS}")
set_property(GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_NAME}_LIBRARIES"
"${AUDIOCPP_REGISTERED_FRONTEND_LIBRARIES}")
set_property(GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_NAME}_COMPILE_DEFINITIONS"
"${AUDIOCPP_REGISTERED_FRONTEND_COMPILE_DEFINITIONS}")
endfunction()
function(audiocpp_collect_server_frontends)
get_property(AUDIOCPP_SERVER_FRONTENDS_ALREADY_COLLECTED GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTENDS_COLLECTED" SET)
if (AUDIOCPP_SERVER_FRONTENDS_ALREADY_COLLECTED)
return()
endif()
set_property(GLOBAL PROPERTY "AUDIOCPP_SERVER_FRONTENDS_COLLECTED" TRUE)
set(AUDIOCPP_SERVER_FRONTEND_DECLARATIONS "")
set(AUDIOCPP_SERVER_FRONTEND_REGISTRATIONS "")
set(AUDIOCPP_SERVER_FRONTEND_SOURCES "")
set(AUDIOCPP_SERVER_FRONTEND_INCLUDE_DIRS "")
set(AUDIOCPP_SERVER_FRONTEND_LIBRARIES "")
set(AUDIOCPP_SERVER_FRONTEND_COMPILE_DEFINITIONS "")
if (AUDIOCPP_BUILD_SERVER_FRONTENDS AND AUDIOCPP_SERVER_FRONTEND_MODULES)
if (NOT AUDIOCPP_SERVER_FRONTENDS_DIR)
message(FATAL_ERROR
"AUDIOCPP_BUILD_SERVER_FRONTENDS=ON requires AUDIOCPP_SERVER_FRONTENDS_DIR=<external frontend package>")
endif()
get_filename_component(
AUDIOCPP_SERVER_FRONTENDS_DIR_ABS
"${AUDIOCPP_SERVER_FRONTENDS_DIR}"
ABSOLUTE
BASE_DIR "${PROJECT_SOURCE_DIR}")
if (NOT EXISTS "${AUDIOCPP_SERVER_FRONTENDS_DIR_ABS}/server_frontends.cmake")
message(FATAL_ERROR
"AUDIOCPP_SERVER_FRONTENDS_DIR must contain server_frontends.cmake: ${AUDIOCPP_SERVER_FRONTENDS_DIR_ABS}")
endif()
set_property(GLOBAL PROPERTY "AUDIOCPP_SERVER_FRONTEND_REGISTERED_MODULES" "")
include("${AUDIOCPP_SERVER_FRONTENDS_DIR_ABS}/server_frontends.cmake")
get_property(AUDIOCPP_REGISTERED_SERVER_FRONTENDS GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_REGISTERED_MODULES")
foreach(AUDIOCPP_SERVER_FRONTEND_MODULE IN LISTS AUDIOCPP_SERVER_FRONTEND_MODULES)
list(FIND AUDIOCPP_REGISTERED_SERVER_FRONTENDS "${AUDIOCPP_SERVER_FRONTEND_MODULE}" AUDIOCPP_SERVER_FRONTEND_INDEX)
if (AUDIOCPP_SERVER_FRONTEND_INDEX EQUAL -1)
message(FATAL_ERROR
"External server frontend package did not register requested module: ${AUDIOCPP_SERVER_FRONTEND_MODULE}")
endif()
get_property(AUDIOCPP_FRONTEND_REGISTER_FUNCTION GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_MODULE}_REGISTER_FUNCTION")
get_property(AUDIOCPP_FRONTEND_SOURCES GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_MODULE}_SOURCES")
get_property(AUDIOCPP_FRONTEND_INCLUDE_DIRS GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_MODULE}_INCLUDE_DIRS")
get_property(AUDIOCPP_FRONTEND_LIBRARIES GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_MODULE}_LIBRARIES")
get_property(AUDIOCPP_FRONTEND_COMPILE_DEFINITIONS GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_${AUDIOCPP_SERVER_FRONTEND_MODULE}_COMPILE_DEFINITIONS")
if (AUDIOCPP_FRONTEND_REGISTER_FUNCTION)
string(APPEND AUDIOCPP_SERVER_FRONTEND_DECLARATIONS
"void ${AUDIOCPP_FRONTEND_REGISTER_FUNCTION}(ServerFrontendRegistry & registry);\n")
string(APPEND AUDIOCPP_SERVER_FRONTEND_REGISTRATIONS
" ${AUDIOCPP_FRONTEND_REGISTER_FUNCTION}(registry);\n")
endif()
list(APPEND AUDIOCPP_SERVER_FRONTEND_SOURCES ${AUDIOCPP_FRONTEND_SOURCES})
list(APPEND AUDIOCPP_SERVER_FRONTEND_INCLUDE_DIRS ${AUDIOCPP_FRONTEND_INCLUDE_DIRS})
list(APPEND AUDIOCPP_SERVER_FRONTEND_LIBRARIES ${AUDIOCPP_FRONTEND_LIBRARIES})
list(APPEND AUDIOCPP_SERVER_FRONTEND_COMPILE_DEFINITIONS ${AUDIOCPP_FRONTEND_COMPILE_DEFINITIONS})
endforeach()
endif()
if (AUDIOCPP_SERVER_FRONTEND_SOURCES)
list(REMOVE_DUPLICATES AUDIOCPP_SERVER_FRONTEND_INCLUDE_DIRS)
list(REMOVE_DUPLICATES AUDIOCPP_SERVER_FRONTEND_LIBRARIES)
list(REMOVE_DUPLICATES AUDIOCPP_SERVER_FRONTEND_COMPILE_DEFINITIONS)
endif()
set_property(GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_SELECTED_DECLARATIONS"
"${AUDIOCPP_SERVER_FRONTEND_DECLARATIONS}")
set_property(GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_SELECTED_REGISTRATIONS"
"${AUDIOCPP_SERVER_FRONTEND_REGISTRATIONS}")
set_property(GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_SELECTED_SOURCES"
"${AUDIOCPP_SERVER_FRONTEND_SOURCES}")
set_property(GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_SELECTED_INCLUDE_DIRS"
"${AUDIOCPP_SERVER_FRONTEND_INCLUDE_DIRS}")
set_property(GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_SELECTED_LIBRARIES"
"${AUDIOCPP_SERVER_FRONTEND_LIBRARIES}")
set_property(GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_SELECTED_COMPILE_DEFINITIONS"
"${AUDIOCPP_SERVER_FRONTEND_COMPILE_DEFINITIONS}")
endfunction()
function(audiocpp_configure_server_frontends AUDIOCPP_SERVER_TARGET)
audiocpp_collect_server_frontends()
get_property(AUDIOCPP_SERVER_FRONTEND_DECLARATIONS GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_SELECTED_DECLARATIONS")
get_property(AUDIOCPP_SERVER_FRONTEND_REGISTRATIONS GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_SELECTED_REGISTRATIONS")
get_property(AUDIOCPP_SERVER_FRONTEND_SOURCES GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_SELECTED_SOURCES")
get_property(AUDIOCPP_SERVER_FRONTEND_INCLUDE_DIRS GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_SELECTED_INCLUDE_DIRS")
get_property(AUDIOCPP_SERVER_FRONTEND_LIBRARIES GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_SELECTED_LIBRARIES")
get_property(AUDIOCPP_SERVER_FRONTEND_COMPILE_DEFINITIONS GLOBAL PROPERTY
"AUDIOCPP_SERVER_FRONTEND_SELECTED_COMPILE_DEFINITIONS")
file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/generated")
if (AUDIOCPP_SERVER_FRONTEND_SOURCES)
add_library(audiocpp_server_frontends OBJECT
${AUDIOCPP_SERVER_FRONTEND_SOURCES}
)
target_include_directories(audiocpp_server_frontends PRIVATE
"${PROJECT_SOURCE_DIR}/app/server"
${AUDIOCPP_SERVER_FRONTEND_INCLUDE_DIRS}
)
target_link_libraries(audiocpp_server_frontends PUBLIC engine_runtime PRIVATE
${AUDIOCPP_SERVER_FRONTEND_LIBRARIES})
target_compile_definitions(audiocpp_server_frontends PRIVATE
${AUDIOCPP_SERVER_FRONTEND_COMPILE_DEFINITIONS})
target_sources(${AUDIOCPP_SERVER_TARGET} PRIVATE $<TARGET_OBJECTS:audiocpp_server_frontends>)
target_link_libraries(${AUDIOCPP_SERVER_TARGET} PRIVATE
audiocpp_server_frontends
${AUDIOCPP_SERVER_FRONTEND_LIBRARIES})
target_compile_definitions(${AUDIOCPP_SERVER_TARGET} PRIVATE
${AUDIOCPP_SERVER_FRONTEND_COMPILE_DEFINITIONS})
endif()
file(WRITE
"${CMAKE_CURRENT_BINARY_DIR}/generated/server_frontend_module_declarations.inc"
"${AUDIOCPP_SERVER_FRONTEND_DECLARATIONS}")
file(WRITE
"${CMAKE_CURRENT_BINARY_DIR}/generated/server_frontend_module_registrations.inc"
"${AUDIOCPP_SERVER_FRONTEND_REGISTRATIONS}")
endfunction()

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,145 @@
#pragma once
#include "config.h"
#include "http.h"
#include "multipart.h"
#include "engine/framework/io/json.h"
#include "engine/framework/runtime/session.h"
#include <chrono>
#include <filesystem>
#include <functional>
#include <optional>
#include <sstream>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace minitts::server {
using ServerClock = std::chrono::steady_clock;
std::optional<int>
parse_busy_timeout_override(const engine::io::json::Value &body);
bool model_accepts_request_option(
std::string_view family, std::string_view option,
const std::optional<std::filesystem::path> &model_spec_override,
const std::filesystem::path &model_path);
std::string model_request_params_json(
std::string_view family,
const std::optional<std::filesystem::path> &model_spec_override,
const std::filesystem::path &model_path);
std::string json_quote(std::string_view value);
std::filesystem::path resolve_path(const std::filesystem::path &base,
const std::filesystem::path &path);
std::optional<std::string>
load_voice_library_text(const std::filesystem::path &voice_dir,
const std::string &voice_id);
std::optional<std::filesystem::path>
resolve_voice_library_wav(const std::filesystem::path &voice_dir,
const std::string &voice_id);
std::unordered_map<std::string, std::string>
options_from_object(const engine::io::json::Value *value);
struct RuntimeVoicePreset {
std::optional<std::string> voice_id;
std::optional<engine::runtime::AudioBuffer> audio;
std::optional<std::string> reference_text;
std::unordered_map<std::string, std::string> options;
};
struct SpeechRequestContext {
const std::filesystem::path &request_base;
const std::optional<std::filesystem::path> &voice_dir;
const std::unordered_map<std::string, RuntimeVoicePreset> &voice_presets;
const std::optional<RuntimeVoicePreset> &default_voice_preset;
bool accepts_reference_text = true;
bool accepts_speed = true;
bool accepts_speaking_rate = true;
const std::unordered_map<std::string, std::string> &default_options;
};
engine::runtime::TaskRequest build_speech_request(
const engine::io::json::Value &body,
const SpeechRequestContext &context);
engine::runtime::TaskRequest apply_default_request_options(
const std::unordered_map<std::string, std::string> &default_options,
engine::runtime::TaskRequest request);
std::string safe_upload_name(std::string value);
ServerModelConfig
model_config_from_json(const engine::io::json::Value &body,
const std::filesystem::path &request_base, bool lazy);
std::string query_param(const std::string &query, const std::string &key);
std::string decoded_query_param(const std::string &query,
const std::string &key);
const char *backend_name(engine::core::BackendType type);
HttpResponse ui_service_worker_retirement_response();
std::string
options_json(const std::unordered_map<std::string, std::string> &options);
void add_option_from_json(std::unordered_map<std::string, std::string> &options,
const engine::io::json::Value &object,
const std::string &field,
const std::string &option_key);
std::vector<uint8_t>
encode_pcm16_wav(const engine::runtime::AudioBuffer &audio);
std::vector<uint8_t>
encode_pcm16_samples(const engine::runtime::AudioBuffer &audio);
void write_sse(HttpStreamWriter &writer, const std::string &json);
void write_sse_done(HttpStreamWriter &writer);
bool bool_field(const engine::io::json::Value &object, const std::string &key,
bool default_value);
HttpResponse sse_response(std::function<void(HttpStreamWriter &)> stream);
HttpResponse
chunked_audio_response(std::function<void(HttpStreamWriter &)> stream);
bool is_wav_upload_filename(const std::string &filename);
void log_request_body_if_enabled(const ServerConfig &config,
const HttpRequest &request);
void log_multipart_request_summary_if_enabled(
const ServerConfig &config, const std::vector<MultipartPart> &parts);
double elapsed_ms(ServerClock::time_point started);
double audio_duration_ms(const engine::runtime::AudioBuffer &audio);
double audio_rtf(double wall_ms, double duration_ms);
std::string timing_json(double wall_ms);
std::string timing_json(double wall_ms,
const engine::runtime::AudioBuffer &audio);
std::string ttft_timing_json(double ttft_ms);
std::string live_speech_timing_json(double request_start_to_first_audio_ms,
double input_end_ms);
bool stream_event_has_output(const engine::runtime::StreamEvent &event, bool include_artifacts = false);
bool task_result_has_output(const engine::runtime::TaskResult &result);
double require_ttft_ms(const std::optional<double> &ttft_ms);
double require_input_end_ms(const std::optional<double> &input_end_ms);
std::unordered_map<std::string, std::string>
timing_headers(double wall_ms, const engine::runtime::AudioBuffer &audio);
std::string
diarization_event_json(const std::vector<engine::runtime::SpeakerTurn> &turns,
int sample_rate, bool final,
const std::optional<double> &ttft_ms = std::nullopt);
void write_transcript_detail_fields(std::ostringstream &out,
const engine::runtime::TaskResult &result);
std::string
task_result_json_with_timing(const engine::runtime::TaskResult &result,
const std::string &timing);
std::string task_result_json(const engine::runtime::TaskResult &result,
double wall_ms);
std::string alignment_result_json(const engine::runtime::TaskResult &result,
const engine::runtime::AudioBuffer &audio,
double wall_ms);
std::string
streaming_task_result_json(const engine::runtime::TaskResult &result,
const std::optional<double> &ttft_ms);
std::string stream_event_json(const engine::runtime::StreamEvent &event,
bool diarization, bool include_artifacts = false);
const engine::runtime::AudioBuffer &
select_audio_output(const engine::runtime::TaskResult &result);
engine::runtime::TaskRequest build_openai_transcription_request(
const engine::io::json::Value &body, const std::filesystem::path &base_dir,
bool accepts_language_option,
const std::string *uploaded_audio_bytes = nullptr);
std::optional<std::filesystem::path>
find_repository_root(const std::filesystem::path &request_base,
const std::filesystem::path &resource_anchor);
} // namespace minitts::server

433
app/server/server_ui.cpp Normal file
View file

@ -0,0 +1,433 @@
#include "server_ui.h"
#include "server_protocol.h"
#include "ui_assets.h"
#if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER)
#include "model_installer.h"
#include "engine/framework/runtime/registry.h"
#endif
#include "engine/framework/io/json.h"
#include <algorithm>
#include <fstream>
#include <iterator>
#include <stdexcept>
#include <system_error>
#include <vector>
namespace minitts::server {
std::filesystem::path resolve_ui_model_path(
const std::filesystem::path &path,
const std::filesystem::path &request_base,
const std::filesystem::path &repository_root) {
if (path.is_absolute()) {
return path;
}
const auto relative = path.lexically_normal();
const auto generic = relative.generic_string();
if (!repository_root.empty() &&
(generic == "assets/framework" || generic.rfind("assets/framework/", 0) == 0)) {
return (repository_root / relative).lexically_normal();
}
return (request_base / relative).lexically_normal();
}
HttpResponse ui_path_status_response(
const std::string &body_text,
bool ui_management,
const std::filesystem::path &request_base,
const std::filesystem::path &repository_root) {
if (!ui_management) {
return error_response(403, "UI path inspection is disabled", "forbidden");
}
const auto body = engine::io::json::parse(body_text);
const auto path = resolve_ui_model_path(
engine::io::json::require_string(body, "path"), request_base, repository_root);
std::error_code ec;
const bool exists = std::filesystem::exists(path, ec);
const bool directory = exists && std::filesystem::is_directory(path, ec);
const bool regular_file = exists && std::filesystem::is_regular_file(path, ec);
return json_response(
"{\"path\":" + json_quote(path.string()) +
",\"exists\":" + (exists ? "true" : "false") +
",\"directory\":" + (directory ? "true" : "false") +
",\"file\":" + (regular_file ? "true" : "false") + "}");
}
HttpResponse ui_upload_response(
const HttpRequest &request,
bool enabled,
const std::function<std::filesystem::path(std::string_view)> &make_path) {
if (!enabled) {
return error_response(403, "UI uploads are disabled", "forbidden");
}
if (request.body.empty()) {
return error_response(400, "upload body is empty", "invalid_request_error");
}
constexpr size_t kMaxUploadBytes = size_t{2} * 1024 * 1024 * 1024;
if (request.body.size() > kMaxUploadBytes) {
return error_response(413, "upload exceeds the 2 GiB limit", "invalid_request_error");
}
std::string filename = "audio.wav";
if (const auto it = request.headers.find("x-audiocpp-filename"); it != request.headers.end()) {
filename = safe_upload_name(it->second);
}
const auto path = make_path(filename);
std::ofstream out(path, std::ios::binary);
if (!out) {
throw std::runtime_error("could not create temporary upload: " + path.string());
}
out.write(request.body.data(), static_cast<std::streamsize>(request.body.size()));
if (!out) {
throw std::runtime_error("could not write temporary upload: " + path.string());
}
return json_response(
"{\"path\":" + json_quote(path.string()) +
",\"bytes\":" + std::to_string(request.body.size()) + "}");
}
HttpResponse ui_asset_response(bool ui_enabled) {
if (!ui_enabled) {
return error_response(404, "WebUI is disabled", "not_found");
}
HttpResponse response;
response.status = 200;
response.content_type = "text/html; charset=utf-8";
const auto html = embedded_ui_html();
response.body.assign(html.data(), html.size());
// The WebUI shares the server origin (usually localhost:8080) with any app
// that previously occupied that port. Never let an old shell survive a
// server upgrade, and ask the browser to discard only cached resources.
// Deliberately omit the Clear-Site-Data "storage" directive: saved voices,
// the selected models folder, and UI preferences live in local storage.
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0";
response.headers["Clear-Site-Data"] = "\"cache\"";
response.headers["Expires"] = "0";
response.headers["Pragma"] = "no-cache";
response.headers["Content-Security-Policy"] =
"default-src 'self' 'unsafe-inline' blob: data:; connect-src 'self'; media-src 'self' blob: data:";
response.headers["X-Content-Type-Options"] = "nosniff";
return response;
}
HttpResponse ui_voice_preview_response(
const HttpRequest &request,
bool ui_enabled,
const std::optional<std::filesystem::path> &voice_dir) {
if (!ui_enabled) {
return error_response(404, "WebUI is disabled", "not_found");
}
if (!voice_dir.has_value()) {
return error_response(404, "voice library is not configured", "not_found");
}
const std::string voice = decoded_query_param(request.query, "voice");
const auto wav = resolve_voice_library_wav(*voice_dir, voice);
if (!wav.has_value()) {
return error_response(404, "voice preview is not available", "not_found");
}
const auto file_size = std::filesystem::file_size(*wav);
std::ifstream file(*wav, std::ios::binary);
if (!file) {
return error_response(404, "voice preview is not available", "not_found");
}
std::uintmax_t offset = 0;
std::uintmax_t count = file_size;
bool partial = false;
if (const auto range = request.headers.find("range"); range != request.headers.end() &&
range->second.rfind("bytes=", 0) == 0) {
const std::string spec = range->second.substr(6);
const size_t dash = spec.find('-');
if (dash != std::string::npos && dash > 0) {
const std::uintmax_t begin = std::stoull(spec.substr(0, dash));
const std::uintmax_t end = dash + 1 < spec.size()
? std::stoull(spec.substr(dash + 1))
: file_size - 1;
if (begin >= file_size || end < begin) {
HttpResponse response;
response.status = 416;
response.content_type = "text/plain";
response.headers["Content-Range"] = "bytes */" + std::to_string(file_size);
response.headers["Accept-Ranges"] = "bytes";
return response;
}
offset = begin;
count = std::min(end, file_size - 1) - begin + 1;
partial = true;
}
}
HttpResponse response;
response.status = partial ? 206 : 200;
response.content_type = "audio/wav";
if (partial) {
file.seekg(static_cast<std::streamoff>(offset), std::ios::beg);
response.body.resize(static_cast<size_t>(count));
file.read(response.body.data(), static_cast<std::streamsize>(response.body.size()));
response.body.resize(static_cast<size_t>(file.gcount()));
response.headers["Content-Range"] =
"bytes " + std::to_string(offset) + "-" +
std::to_string(offset + response.body.size() - 1) + "/" +
std::to_string(file_size);
} else {
response.body.assign(
std::istreambuf_iterator<char>(file),
std::istreambuf_iterator<char>());
}
response.headers["Accept-Ranges"] = "bytes";
response.headers["Cache-Control"] = "no-store";
response.headers["X-Content-Type-Options"] = "nosniff";
return response;
}
#if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER)
UiModelInstallRequest parse_ui_model_install_request(const std::string &body_text) {
const auto body = engine::io::json::parse(body_text);
UiModelInstallRequest request;
request.package_id = engine::io::json::require_string(body, "id");
request.source_directory =
engine::io::json::optional_string(body, "source_directory", "");
request.source_file =
engine::io::json::optional_string(body, "source_file", "");
request.output_file =
engine::io::json::optional_string(body, "output_file", "");
request.variant =
engine::io::json::optional_string(body, "variant", "");
request.overwrite =
engine::io::json::optional_bool(body, "overwrite", false);
return request;
}
std::string parse_ui_model_id(const std::string &body_text) {
return engine::io::json::require_string(engine::io::json::parse(body_text), "id");
}
HttpResponse ui_model_install_response(
const UiModelInstallRequest &request,
ModelInstaller &installer) {
return json_response(installer.start(
request.package_id,
request.source_file,
request.output_file,
request.source_directory,
request.variant,
request.overwrite));
}
HttpResponse ui_model_remove_response(
const std::string &package_id,
ModelInstaller &installer) {
return json_response(installer.remove(package_id));
}
HttpResponse ui_model_install_stop_response(
const std::string &package_id,
ModelInstaller &installer) {
return json_response(installer.stop(package_id));
}
HttpResponse ui_model_clean_partial_response(
const std::string &package_id,
ModelInstaller &installer) {
return json_response(installer.clean_partial(package_id));
}
HttpResponse ui_model_install_status_response(
const HttpRequest &request,
const ModelInstaller &installer) {
return json_response(installer.status(query_param(request.query, "id")));
}
HttpResponse ui_model_package_sizes_response(ModelInstaller &installer) {
namespace json = engine::io::json;
auto inventory = json::parse(installer.package_sizes()).as_object();
const auto registry = engine::runtime::make_default_registry();
auto rows = inventory.at("data").as_array();
for (auto & value : rows) {
auto row = value.as_object();
const auto & metadata = row.at("package");
engine::assets::ModelPackage package;
package.root = json::require_string(metadata, "root");
package.format = json::require_string(metadata, "format");
for (const auto & file : metadata.require("files").as_array()) {
package.files.emplace_back(file.as_string());
}
try {
const auto target = registry.resolve_package_load(json::require_string(metadata, "family"), package);
json::Value::Object load_options, session_options;
for (const auto & option : target.load_options) {
load_options[option.first] = json::Value::make_string(option.second);
}
for (const auto & option : target.session_options) {
session_options[option.first] = json::Value::make_string(option.second);
}
json::Value::Object resolved;
resolved["family"] = json::Value::make_string(target.family);
resolved["model_path"] = json::Value::make_string(target.model_path.generic_string());
resolved["load_options"] = json::Value::make_object(std::move(load_options));
resolved["session_options"] = json::Value::make_object(std::move(session_options));
row["load_target"] = json::Value::make_object(std::move(resolved));
} catch (const std::exception & error) {
row["load_target_error"] = json::Value::make_string(error.what());
}
value = json::Value::make_object(std::move(row));
}
inventory["data"] = json::Value::make_array(std::move(rows));
return json_response(json::stringify(json::Value::make_object(std::move(inventory))));
}
std::optional<HttpResponse> ui_model_load_package_id(
const engine::io::json::Value &body,
std::string &package_id) {
package_id.clear();
const auto *package_value = body.find("package_id");
if (package_value == nullptr) {
return std::nullopt;
}
if (!package_value->is_string() || package_value->as_string().empty()) {
return error_response(400, "package_id must be a non-empty string", "invalid_request_error");
}
if (body.find("path") != nullptr) {
return error_response(400, "send either path or package_id, not both", "invalid_request_error");
}
package_id = package_value->as_string();
return std::nullopt;
}
std::optional<HttpResponse> resolve_ui_package_model_load(
const engine::io::json::Value &body,
const std::string &package_id,
const std::optional<engine::package_manager::PackageInfo> &info,
std::string &resolved_body) {
namespace json = engine::io::json;
if (!info) {
return error_response(404, "unknown package id: " + package_id, "not_found");
}
if (!info->installed) {
return error_response(404, "package is not installed: " + package_id, "not_found");
}
engine::runtime::PackageLoadTarget target;
try {
target = engine::runtime::make_default_registry().resolve_package_load(info->family, info->package);
} catch (const std::exception &error) {
return error_response(400, "package " + package_id + " cannot be loaded: " + error.what(),
"invalid_request_error");
}
auto fields = body.as_object();
if (const auto found = fields.find("family"); found == fields.end()) {
fields["family"] = json::Value::make_string(target.family);
} else if (!found->second.is_string() || found->second.as_string() != target.family) {
return error_response(400,
"package " + package_id + " is for family " + target.family,
"invalid_request_error");
}
for (const char *key : {"load_options", "session_options"}) {
const auto *options = body.find(key);
if (options && !options->is_object() && !options->is_null()) {
return error_response(400, std::string(key) + " must be an object or null", "invalid_request_error");
}
}
// Resolved options fill in what the request leaves out; keys it sends win.
const auto merge_options = [&](const char *key, const std::unordered_map<std::string, std::string> &resolved) {
if (resolved.empty()) return;
json::Value::Object options;
if (const auto found = fields.find(key); found != fields.end() && found->second.is_object()) {
options = found->second.as_object();
}
for (const auto &option : resolved) {
options.emplace(option.first, json::Value::make_string(option.second));
}
fields[key] = json::Value::make_object(std::move(options));
};
merge_options("load_options", target.load_options);
merge_options("session_options", target.session_options);
fields.erase("package_id");
fields["path"] = json::Value::make_string(target.model_path.string());
resolved_body = json::stringify(json::Value::make_object(std::move(fields)));
return std::nullopt;
}
HttpResponse ui_models_root_response(
const std::filesystem::path &models_root,
const std::filesystem::path &default_models_root) {
return json_response(
"{\"models_root\":" + json_quote(models_root.string()) +
",\"default_models_root\":" + json_quote(default_models_root.string()) +
",\"is_default\":" + (models_root == default_models_root ? "true" : "false") + "}");
}
HttpResponse ui_directory_browser_response(
const std::string &requested_path,
const std::filesystem::path &models_root,
const std::filesystem::path &request_base) {
auto current = requested_path.empty() ? models_root : std::filesystem::path(requested_path);
if (current.is_relative()) {
current = request_base / current;
}
current = std::filesystem::absolute(current).lexically_normal();
std::error_code error;
if (!std::filesystem::is_directory(current, error)) {
return error_response(
400, "folder is not accessible: " + current.string(), "invalid_request_error");
}
std::vector<std::filesystem::path> roots;
#ifdef _WIN32
for (char drive = 'A'; drive <= 'Z'; ++drive) {
const auto root = std::filesystem::path(std::string(1, drive) + ":\\");
error.clear();
if (std::filesystem::is_directory(root, error)) {
roots.push_back(root);
}
}
#else
roots.emplace_back("/");
#endif
struct DirectoryEntry {
std::string name;
std::filesystem::path path;
};
std::vector<DirectoryEntry> directories;
error.clear();
std::filesystem::directory_iterator iterator(
current, std::filesystem::directory_options::skip_permission_denied, error);
const std::filesystem::directory_iterator end;
while (!error && iterator != end) {
std::error_code entry_error;
if (iterator->is_directory(entry_error)) {
directories.push_back(DirectoryEntry{
iterator->path().filename().string(),
iterator->path().lexically_normal(),
});
}
iterator.increment(error);
}
std::sort(directories.begin(), directories.end(), [](const auto &left, const auto &right) {
return left.name < right.name;
});
auto parent = current.parent_path();
if (parent == current) {
parent.clear();
}
std::string response =
"{\"current\":" + json_quote(current.string()) +
",\"parent\":" + json_quote(parent.string()) + ",\"roots\":[";
for (size_t index = 0; index < roots.size(); ++index) {
if (index > 0) response += ",";
response += json_quote(roots[index].string());
}
response += "],\"directories\":[";
for (size_t index = 0; index < directories.size(); ++index) {
if (index > 0) response += ",";
response += "{\"name\":" + json_quote(directories[index].name) +
",\"path\":" + json_quote(directories[index].path.string()) + "}";
}
return json_response(response + "]}");
}
#endif
} // namespace minitts::server

89
app/server/server_ui.h Normal file
View file

@ -0,0 +1,89 @@
#pragma once
#include "http.h"
#if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER)
#include "engine/framework/io/json.h"
#include "engine/framework/package_manager/manager.h"
#endif
#include <filesystem>
#include <functional>
#include <optional>
#include <string>
namespace minitts::server {
std::filesystem::path resolve_ui_model_path(
const std::filesystem::path &path,
const std::filesystem::path &request_base,
const std::filesystem::path &repository_root);
HttpResponse ui_path_status_response(
const std::string &body_text,
bool ui_management,
const std::filesystem::path &request_base,
const std::filesystem::path &repository_root);
HttpResponse ui_upload_response(
const HttpRequest &request,
bool enabled,
const std::function<std::filesystem::path(std::string_view)> &make_path);
HttpResponse ui_asset_response(bool ui_enabled);
HttpResponse ui_voice_preview_response(
const HttpRequest &request,
bool ui_enabled,
const std::optional<std::filesystem::path> &voice_dir);
#if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER)
class ModelInstaller;
struct UiModelInstallRequest {
std::string package_id;
std::string source_directory;
std::string source_file;
std::string output_file;
std::string variant;
bool overwrite = false;
};
UiModelInstallRequest parse_ui_model_install_request(const std::string &body_text);
std::string parse_ui_model_id(const std::string &body_text);
HttpResponse ui_model_install_response(
const UiModelInstallRequest &request,
ModelInstaller &installer);
HttpResponse ui_model_remove_response(
const std::string &package_id,
ModelInstaller &installer);
HttpResponse ui_model_install_stop_response(
const std::string &package_id,
ModelInstaller &installer);
HttpResponse ui_model_clean_partial_response(
const std::string &package_id,
ModelInstaller &installer);
HttpResponse ui_model_install_status_response(
const HttpRequest &request,
const ModelInstaller &installer);
HttpResponse ui_model_package_sizes_response(ModelInstaller &installer);
// POST /v1/models/load may name an installed package by "package_id" instead
// of "path". Sets package_id to the requested id, or leaves it empty for a
// path-based request; returns an error response for a malformed package_id.
std::optional<HttpResponse> ui_model_load_package_id(
const engine::io::json::Value &body,
std::string &package_id);
// Rewrites a package_id load into the path-based request the loader already
// takes, using the model-owned resolve_package_load for the path, family, load
// options and session options. Returns the error response instead when the
// package cannot be loaded by id.
std::optional<HttpResponse> resolve_ui_package_model_load(
const engine::io::json::Value &body,
const std::string &package_id,
const std::optional<engine::package_manager::PackageInfo> &info,
std::string &resolved_body);
HttpResponse ui_models_root_response(
const std::filesystem::path &models_root,
const std::filesystem::path &default_models_root);
HttpResponse ui_directory_browser_response(
const std::string &requested_path,
const std::filesystem::path &models_root,
const std::filesystem::path &request_base);
#endif
} // namespace minitts::server

View file

@ -0,0 +1,26 @@
#pragma once
#include <string_view>
namespace minitts::server {
struct NeutralSpeechOptionRule {
std::string_view name;
double value;
};
// Top-level OpenAI speech compatibility exceptions: these neutral values behave
// like an omitted field, even when the model does not support the option.
// Check after value validation and alias normalization, before model forwarding.
inline constexpr NeutralSpeechOptionRule kNeutralSpeechOptions[] = {
{"speed", 1.0},
};
inline bool is_neutral_speech_option(std::string_view name, double value) {
for (const auto & rule : kNeutralSpeechOptions) {
if (rule.name == name && rule.value == value) return true;
}
return false;
}
} // namespace minitts::server

View file

@ -0,0 +1,34 @@
{
"host": "127.0.0.1",
"port": 8080,
"backend": "cuda",
"device": 0,
"threads": 1,
"lazy_load": true,
"live_ingest": {
"idle_timeout_ms": 30000,
"total_timeout_ms": 600000,
"max_body_bytes": 536870912,
"max_chunk_bytes": 8388608,
"send_timeout_ms": 30000
},
"models": [
{
"id": "voxcpm2-stream",
"family": "voxcpm2",
"path": "/path/to/models/VoxCPM2",
"task": "tts",
"mode": "streaming"
},
{
"id": "nemotron-stream",
"family": "nemotron_asr",
"path": "/path/to/models/nemotron-3.5-asr-streaming-0.6b",
"task": "asr",
"mode": "streaming",
"live_ingest": {
"total_timeout_ms": 1800000
}
}
]
}

View file

@ -0,0 +1,37 @@
#pragma once
#include "engine/framework/io/json.h"
#include "engine/framework/runtime/errors.h"
#include <string>
namespace minitts::server {
// /v1/tasks/stream answers with one JSON body once the run is over, unless the
// request's top-level "stream_format" is "sse": then each event goes out as a
// server-sent event as the model produces it. Absent or null keeps the JSON
// body. Any other value is turned away, so that a misspelt one is not answered
// with a body the client did not ask for.
inline bool task_stream_sse_requested(const engine::io::json::Value & body) {
const auto * value = body.find("stream_format");
if (value == nullptr || value->is_null()) {
return false;
}
if (value->is_string() && value->as_string() == "sse") {
return true;
}
throw engine::runtime::InvalidRequestError("task stream stream_format must be sse");
}
// The data of the SSE lines, the same in both runtimes: each event as the
// object the JSON response puts in "events", then the object it puts under
// "result".
inline std::string task_stream_event_line(const std::string & event_json) {
return "{\"type\":\"task.stream.event\",\"event\":" + event_json + "}";
}
inline std::string task_stream_done_line(const std::string & result_json) {
return "{\"type\":\"task.stream.done\",\"result\":" + result_json + "}";
}
} // namespace minitts::server

38
app/server/ui_assets.cpp Normal file
View file

@ -0,0 +1,38 @@
#include "ui_assets.h"
#include "audiocpp_demo_voice_assets.h"
#include "audiocpp_ui_asset.h"
namespace minitts::server {
namespace {
template <std::size_t Size>
std::string_view byte_view(const unsigned char (&bytes)[Size]) noexcept {
return {reinterpret_cast<const char *>(bytes), Size};
}
} // namespace
std::string_view embedded_ui_html() noexcept {
return {
reinterpret_cast<const char *>(kAudioCppUiHtml),
kAudioCppUiHtmlSize,
};
}
const std::array<EmbeddedDemoVoice, 4> & embedded_demo_voices() noexcept {
static const std::array<EmbeddedDemoVoice, 4> voices{{
{"demo_1_man", byte_view(kAudioCppDemo1ManWav)},
{"demo_2_man", byte_view(kAudioCppDemo2ManWav)},
{"demo_3_woman", byte_view(kAudioCppDemo3WomanWav)},
{"demo_4_woman", byte_view(kAudioCppDemo4WomanWav)},
}};
return voices;
}
std::string_view embedded_demo_voice_prompt_text() noexcept {
return byte_view(kAudioCppDemoVoicePromptText);
}
} // namespace minitts::server

17
app/server/ui_assets.h Normal file
View file

@ -0,0 +1,17 @@
#pragma once
#include <array>
#include <string_view>
namespace minitts::server {
struct EmbeddedDemoVoice {
std::string_view name;
std::string_view wav_bytes;
};
std::string_view embedded_ui_html() noexcept;
const std::array<EmbeddedDemoVoice, 4> & embedded_demo_voices() noexcept;
std::string_view embedded_demo_voice_prompt_text() noexcept;
} // namespace minitts::server

View file

@ -0,0 +1,106 @@
#include "pcm_source.h"
#include <cstdint>
#include <cstring>
#include <iostream>
#include <stdexcept>
#include <vector>
#ifdef _WIN32
#include <fcntl.h>
#include <io.h>
#include <cstdio>
#endif
namespace minitts::app {
namespace {
// Decoded little-endian so a stream produced on one machine reads back the same on any host.
float decode_s16le(const unsigned char * bytes) {
const auto raw = static_cast<uint16_t>(
static_cast<uint16_t>(bytes[0]) | (static_cast<uint16_t>(bytes[1]) << 8));
// Matches the scaling engine::audio::read_wav_f32 applies, so raw PCM and WAV input of the
// same audio decode to identical samples.
return static_cast<float>(static_cast<int16_t>(raw)) / 32768.0F;
}
float decode_f32le(const unsigned char * bytes) {
const auto raw = static_cast<uint32_t>(
static_cast<uint32_t>(bytes[0]) |
(static_cast<uint32_t>(bytes[1]) << 8) |
(static_cast<uint32_t>(bytes[2]) << 16) |
(static_cast<uint32_t>(bytes[3]) << 24));
float value = 0.0F;
std::memcpy(&value, &raw, sizeof(value));
return value;
}
int pcm_sample_format_bytes(PcmSampleFormat format) {
return format == PcmSampleFormat::S16LE ? 2 : 4;
}
// Required on Windows, where stdin's default text mode mangles PCM bytes; a no-op elsewhere.
void set_stdin_binary_mode() {
#ifdef _WIN32
if (_setmode(_fileno(stdin), _O_BINARY) == -1) {
throw std::runtime_error("failed to switch stdin to binary mode");
}
#endif
}
} // namespace
PcmSampleFormat parse_pcm_sample_format(std::string_view name) {
if (name == "s16le") return PcmSampleFormat::S16LE;
if (name == "f32le") return PcmSampleFormat::F32LE;
throw std::runtime_error(
"unsupported raw PCM sample format '" + std::string(name) + "' (expected s16le or f32le)");
}
std::string to_string(PcmSampleFormat format) {
return format == PcmSampleFormat::S16LE ? "s16le" : "f32le";
}
AudioChunkStream make_pcm_chunk_stream(
std::istream & input,
AudioStreamFormat format,
PcmSampleFormat sample_format) {
if (format.sample_rate <= 0 || format.channels <= 0) {
throw std::runtime_error("raw PCM input requires a positive sample rate and channel count");
}
const int sample_bytes = pcm_sample_format_bytes(sample_format);
const size_t frame_bytes = static_cast<size_t>(sample_bytes) * static_cast<size_t>(format.channels);
const auto decode = sample_format == PcmSampleFormat::S16LE ? &decode_s16le : &decode_f32le;
AudioChunkStream stream;
stream.format = format;
stream.read = [source = &input, decode, sample_bytes, frame_bytes, bytes = std::vector<char>{}](
int64_t max_samples, std::vector<float> & samples) mutable {
if (max_samples <= 0) {
throw std::runtime_error("raw PCM read requires a positive sample count");
}
bytes.resize(static_cast<size_t>(max_samples) * static_cast<size_t>(sample_bytes));
source->read(bytes.data(), static_cast<std::streamsize>(bytes.size()));
const auto received = static_cast<size_t>(source->gcount());
// A short read means end of input; drop a trailing partial frame, which cannot be
// interpreted, so the chunk always holds whole frames.
const size_t usable = (received / frame_bytes) * frame_bytes;
const size_t count = usable / static_cast<size_t>(sample_bytes);
samples.resize(count);
const auto * raw = reinterpret_cast<const unsigned char *>(bytes.data());
for (size_t i = 0; i < count; ++i) {
samples[i] = decode(raw + i * static_cast<size_t>(sample_bytes));
}
return received == bytes.size();
};
return stream;
}
AudioChunkStream make_stdin_pcm_stream(AudioStreamFormat format, PcmSampleFormat sample_format) {
set_stdin_binary_mode();
return make_pcm_chunk_stream(std::cin, format, sample_format);
}
} // namespace minitts::app

View file

@ -0,0 +1,32 @@
#pragma once
#include "streaming.h"
#include <istream>
#include <string>
#include <string_view>
namespace minitts::app {
// Sample encodings accepted for raw (headerless) PCM input.
enum class PcmSampleFormat {
S16LE,
F32LE,
};
PcmSampleFormat parse_pcm_sample_format(std::string_view name);
std::string to_string(PcmSampleFormat format);
// Streams raw interleaved PCM from `input` without buffering the whole stream. Blocks until a
// full block is available, a short block can be completed at end of input, or the stream ends.
// `input` must outlive the returned stream.
AudioChunkStream make_pcm_chunk_stream(
std::istream & input,
AudioStreamFormat format,
PcmSampleFormat sample_format);
// Streams raw interleaved PCM from process stdin, switching the handle to binary mode first
// (required on Windows, where the default text mode mangles PCM bytes).
AudioChunkStream make_stdin_pcm_stream(AudioStreamFormat format, PcmSampleFormat sample_format);
} // namespace minitts::app

170
app/streaming/streaming.cpp Normal file
View file

@ -0,0 +1,170 @@
#include "streaming.h"
#include <algorithm>
#include <cstddef>
#include <cmath>
#include <stdexcept>
#include <utility>
#include <vector>
namespace minitts::app {
namespace {
void emit_if_nonempty(const engine::runtime::StreamEvent & event, const StreamEventSink & sink) {
if (event.voice_activity.empty() &&
!event.partial_text.has_value() &&
!event.partial_text_snapshot.has_value() &&
!event.audio_output.has_value() &&
event.named_audio_outputs.empty() &&
event.speaker_turns.empty() &&
event.word_timestamps.empty() &&
event.output_artifacts.empty() &&
!event.is_final) {
return;
}
if (sink) {
sink(event);
}
}
int64_t resolve_chunk_samples(
const engine::runtime::StreamingPolicy & policy,
const AudioStreamFormat & format) {
if (policy.preferred_audio_chunk_seconds > 0.0) {
return static_cast<int64_t>(std::llround(
policy.preferred_audio_chunk_seconds * static_cast<double>(format.sample_rate))) *
static_cast<int64_t>(format.channels);
}
return policy.preferred_audio_chunk_samples;
}
void feed_audio_stream(
engine::runtime::IStreamingVoiceTaskSession & session,
const AudioChunkStream & stream,
int64_t chunk_samples,
const StreamEventSink & sink) {
if (chunk_samples <= 0) {
throw std::runtime_error("streaming audio chunk size must be positive");
}
if (stream.format.sample_rate <= 0 || stream.format.channels <= 0) {
throw std::runtime_error("streaming audio input requires positive sample rate and channels");
}
if (!stream.read) {
throw std::runtime_error("streaming audio input requires a chunk reader");
}
int64_t start_sample = 0;
std::vector<float> samples;
while (true) {
samples.clear();
const bool has_more = stream.read(chunk_samples, samples);
if (samples.size() % static_cast<size_t>(stream.format.channels) != 0) {
throw std::runtime_error("streaming audio input sample count must be divisible by channels");
}
if (!samples.empty()) {
const auto consumed = static_cast<int64_t>(samples.size());
emit_if_nonempty(
session.process_audio_chunk({
stream.format.sample_rate,
stream.format.channels,
start_sample,
std::move(samples),
}),
sink);
start_sample += consumed;
}
if (!has_more) {
break;
}
}
}
// Replays an already materialized buffer through the same path a live source takes, so the two
// input routes cannot drift apart. `audio` must outlive the returned stream.
AudioChunkStream buffer_audio_stream(const engine::runtime::AudioBuffer & audio) {
// The whole length is known here, unlike a live source, so reject a malformed buffer before
// the session is touched rather than partway through it on the final short chunk.
if (audio.channels <= 0 || audio.samples.size() % static_cast<size_t>(audio.channels) != 0) {
throw std::runtime_error("streaming audio input sample count must be divisible by channels");
}
AudioChunkStream stream;
stream.format = AudioStreamFormat{audio.sample_rate, audio.channels};
stream.read = [source = &audio, offset = size_t{0}](
int64_t max_samples, std::vector<float> & samples) mutable {
const size_t step = static_cast<size_t>(max_samples);
const size_t available = std::min(step, source->samples.size() - offset);
samples.assign(
source->samples.begin() + static_cast<std::ptrdiff_t>(offset),
source->samples.begin() + static_cast<std::ptrdiff_t>(offset + available));
offset += available;
return offset < source->samples.size();
};
return stream;
}
void pull_stream_events(
engine::runtime::IStreamingVoiceTaskSession & session,
const StreamEventSink & sink) {
while (true) {
auto event = session.next_stream_event();
if (!event.has_value()) {
break;
}
emit_if_nonempty(*event, sink);
}
}
engine::runtime::TaskResult run_stream(
engine::runtime::IStreamingVoiceTaskSession & session,
const engine::runtime::TaskRequest & request,
const StreamEventSink & sink,
const AudioChunkStream * stream) {
const auto policy = session.streaming_policy();
session.set_stream_event_sink(sink);
try {
session.start_stream(request);
if (policy.input == engine::runtime::StreamingInputKind::AudioChunks) {
if (stream == nullptr) {
throw std::runtime_error(
"streaming audio input mode requires samples in audio_input, or an "
"AudioChunkStream for a live source");
}
feed_audio_stream(session, *stream, resolve_chunk_samples(policy, stream->format), sink);
}
if (policy.output == engine::runtime::StreamingOutputKind::PullEvents) {
pull_stream_events(session, sink);
}
auto result = session.finish_stream();
session.set_stream_event_sink(nullptr);
return result;
} catch (...) {
session.set_stream_event_sink(nullptr);
throw;
}
}
} // namespace
engine::runtime::TaskResult run_streaming_task(
engine::runtime::IStreamingVoiceTaskSession & session,
const engine::runtime::TaskRequest & request,
const StreamEventSink & sink) {
// A live source carries its format in audio_input but no samples, and must be supplied as an
// AudioChunkStream instead. Treating that as an empty buffer here would feed the session
// nothing and fail much later, so leave it to the null-stream check in run_stream.
if (!request.audio_input.has_value() || request.audio_input->samples.empty()) {
return run_stream(session, request, sink, nullptr);
}
const auto stream = buffer_audio_stream(*request.audio_input);
return run_stream(session, request, sink, &stream);
}
engine::runtime::TaskResult run_streaming_task(
engine::runtime::IStreamingVoiceTaskSession & session,
const engine::runtime::TaskRequest & request,
const StreamEventSink & sink,
const AudioChunkStream & stream) {
return run_stream(session, request, sink, &stream);
}
} // namespace minitts::app

45
app/streaming/streaming.h Normal file
View file

@ -0,0 +1,45 @@
#pragma once
#include "engine/framework/runtime/session.h"
#include <cstdint>
#include <functional>
#include <vector>
namespace minitts::app {
using StreamEventSink = std::function<void(const engine::runtime::StreamEvent &)>;
// Format of an incrementally supplied audio stream. A live source has to declare it up front
// because the samples themselves only arrive later.
struct AudioStreamFormat {
int sample_rate = 0;
int channels = 1;
};
// Pulls the next block of interleaved samples into `samples`, at most `max_samples` values
// (`channels * frames`). Returns false once the stream has ended and no further audio will
// arrive; the final block may be short and can be returned together with either result.
// Implementations are expected to block while waiting for a live source.
using AudioChunkReader = std::function<bool(int64_t max_samples, std::vector<float> & samples)>;
struct AudioChunkStream {
AudioStreamFormat format;
AudioChunkReader read;
};
engine::runtime::TaskResult run_streaming_task(
engine::runtime::IStreamingVoiceTaskSession & session,
const engine::runtime::TaskRequest & request,
const StreamEventSink & sink);
// Streams audio from `stream` rather than from `request.audio_input`, so the audio never has to
// be fully materialized. `request` still supplies text, options and the audio format contract
// used to prepare the session.
engine::runtime::TaskResult run_streaming_task(
engine::runtime::IStreamingVoiceTaskSession & session,
const engine::runtime::TaskRequest & request,
const StreamEventSink & sink,
const AudioChunkStream & stream);
} // namespace minitts::app

View file

@ -0,0 +1,88 @@
#include "execution.h"
#include <chrono>
#include <stdexcept>
namespace minitts::app {
namespace {
engine::runtime::AudioBuffer concat_audio_outputs(
const std::vector<AppRequestResult> & results,
std::vector<AudioChapter> & chapters) {
engine::runtime::AudioBuffer merged;
for (const auto & item : results) {
if (!item.result.audio_output.has_value()) {
continue;
}
const auto & audio = *item.result.audio_output;
if (merged.sample_rate == 0) {
merged.sample_rate = audio.sample_rate;
merged.channels = audio.channels;
}
if (audio.sample_rate != merged.sample_rate || audio.channels != merged.channels) {
throw std::runtime_error("cannot merge batch audio outputs with different sample rates or channel counts");
}
const int64_t start = static_cast<int64_t>(merged.samples.size() / static_cast<size_t>(merged.channels));
merged.samples.insert(merged.samples.end(), audio.samples.begin(), audio.samples.end());
const int64_t end = static_cast<int64_t>(merged.samples.size() / static_cast<size_t>(merged.channels));
chapters.push_back(AudioChapter{item.id, start, end});
}
if (merged.sample_rate == 0) {
throw std::runtime_error("batch audio merge requested but no request produced a primary audio output");
}
return merged;
}
} // namespace
AudioMergeMode parse_audio_merge_mode(const std::string & value) {
if (value == "none") {
return AudioMergeMode::None;
}
if (value == "concat") {
return AudioMergeMode::Concat;
}
throw std::runtime_error("--batch-merge-audio must be none or concat");
}
AppBatchResult run_offline_batch(
engine::runtime::IVoiceTaskSession & session,
engine::runtime::IOfflineVoiceTaskSession & offline,
const AppBatchRequest & batch,
AudioMergeMode audio_merge_mode,
const std::function<void(size_t, const AppRequestResult &)> & on_result) {
if (batch.requests.empty()) {
throw std::runtime_error("offline batch requires at least one request");
}
using Clock = std::chrono::steady_clock;
const auto to_ms = [](Clock::duration d) {
return std::chrono::duration<double, std::milli>(d).count();
};
const auto session_start = Clock::now();
const auto prepare_start = Clock::now();
session.prepare(engine::runtime::build_preparation_request(batch.requests.front().request));
AppBatchResult out;
out.prepare_ms = to_ms(Clock::now() - prepare_start);
out.results.reserve(batch.requests.size());
for (const auto & item : batch.requests) {
const auto run_start = Clock::now();
auto result = offline.run(item.request);
const double wall_ms = to_ms(Clock::now() - run_start);
out.results.push_back(AppRequestResult{
item.id,
std::move(result),
wall_ms,
});
if (on_result) {
on_result(out.results.size() - 1, out.results.back());
}
}
out.session_wall_ms = to_ms(Clock::now() - session_start);
if (audio_merge_mode == AudioMergeMode::Concat) {
out.merged_audio = concat_audio_outputs(out.results, out.chapters);
}
return out;
}
} // namespace minitts::app

57
app/workflow/execution.h Normal file
View file

@ -0,0 +1,57 @@
#pragma once
#include "engine/framework/runtime/session.h"
#include <cstddef>
#include <filesystem>
#include <functional>
#include <optional>
#include <string>
#include <vector>
namespace minitts::app {
struct AppRequest {
std::string id;
engine::runtime::TaskRequest request;
};
struct AppRequestResult {
std::string id;
engine::runtime::TaskResult result;
double wall_ms = 0.0;
};
struct AudioChapter {
std::string id;
int64_t start_sample = 0;
int64_t end_sample = 0;
};
struct AppBatchRequest {
std::vector<AppRequest> requests;
};
struct AppBatchResult {
std::vector<AppRequestResult> results;
std::optional<engine::runtime::AudioBuffer> merged_audio;
std::vector<AudioChapter> chapters;
double prepare_ms = 0.0;
double session_wall_ms = 0.0;
};
enum class AudioMergeMode {
None,
Concat,
};
AudioMergeMode parse_audio_merge_mode(const std::string & value);
AppBatchResult run_offline_batch(
engine::runtime::IVoiceTaskSession & session,
engine::runtime::IOfflineVoiceTaskSession & offline,
const AppBatchRequest & batch,
AudioMergeMode audio_merge_mode,
const std::function<void(size_t, const AppRequestResult &)> & on_result = {});
} // namespace minitts::app

561
app/workflow/file_sink.cpp Normal file
View file

@ -0,0 +1,561 @@
#include "file_sink.h"
#include "engine/framework/audio/output.h"
#include "engine/framework/core/host_memory.h"
#include "engine/framework/io/json.h"
#include <algorithm>
#include <cctype>
#include <cstddef>
#include <fstream>
#include <iostream>
#include <sstream>
#include <stdexcept>
#include <vector>
namespace minitts::app {
namespace {
struct MetricsAudioView {
int sample_rate = 0;
int channels = 0;
size_t samples = 0;
const char * source = "";
};
std::string quote_json(const std::string & value) {
return engine::io::json::stringify_string(value);
}
std::string speech_segments_to_json(const std::vector<engine::runtime::SpeechSegment> & segments) {
std::ostringstream out;
out << "[";
for (size_t i = 0; i < segments.size(); ++i) {
if (i != 0) {
out << ",";
}
out << "{\"start_sample\":" << segments[i].span.start_sample
<< ",\"end_sample\":" << segments[i].span.end_sample
<< ",\"confidence\":" << segments[i].confidence;
if (!segments[i].text.empty()) {
out << ",\"text\":" << quote_json(segments[i].text);
}
out << "}";
}
out << "]";
return out.str();
}
std::string speaker_turns_to_json(const std::vector<engine::runtime::SpeakerTurn> & turns) {
std::ostringstream out;
out << "[";
for (size_t i = 0; i < turns.size(); ++i) {
if (i != 0) {
out << ",";
}
out << "{\"start_sample\":" << turns[i].span.start_sample
<< ",\"end_sample\":" << turns[i].span.end_sample
<< ",\"speaker_id\":" << quote_json(turns[i].speaker_id)
<< ",\"confidence\":" << turns[i].confidence;
if (!turns[i].text.empty()) {
out << ",\"text\":" << quote_json(turns[i].text);
}
out << "}";
}
out << "]";
return out.str();
}
const char * artifact_kind_name(engine::runtime::ArtifactKind kind) {
switch (kind) {
case engine::runtime::ArtifactKind::SpeakerEmbedding:
return "speaker_embedding";
case engine::runtime::ArtifactKind::StyleEmbedding:
return "style_embedding";
case engine::runtime::ArtifactKind::PromptEmbedding:
return "prompt_embedding";
case engine::runtime::ArtifactKind::AcousticTokens:
return "acoustic_tokens";
case engine::runtime::ArtifactKind::Midi:
return "midi";
case engine::runtime::ArtifactKind::TranscriptAlignment:
return "transcript_alignment";
case engine::runtime::ArtifactKind::DiarizationState:
return "diarization_state";
case engine::runtime::ArtifactKind::VadState:
return "vad_state";
case engine::runtime::ArtifactKind::Custom:
return "custom";
}
return "unknown";
}
std::string bytes_to_hex(const std::vector<std::byte> & bytes) {
constexpr char kHex[] = "0123456789abcdef";
std::string out;
out.reserve(bytes.size() * 2);
for (const std::byte byte : bytes) {
const auto value = std::to_integer<unsigned int>(byte);
out.push_back(kHex[(value >> 4) & 0x0f]);
out.push_back(kHex[value & 0x0f]);
}
return out;
}
std::string artifact_to_json(const engine::runtime::VoiceArtifact & artifact) {
std::vector<std::string> meta_keys;
meta_keys.reserve(artifact.meta.size());
for (const auto & [key, _] : artifact.meta) {
meta_keys.push_back(key);
}
std::sort(meta_keys.begin(), meta_keys.end());
std::ostringstream out;
out << "{"
<< "\"id\":" << quote_json(artifact.id)
<< ",\"kind\":" << quote_json(artifact_kind_name(artifact.kind))
<< ",\"bytes\":" << artifact.payload.size()
<< ",\"meta\":{";
for (size_t i = 0; i < meta_keys.size(); ++i) {
if (i != 0) {
out << ",";
}
const auto & key = meta_keys[i];
out << quote_json(key) << ":" << quote_json(artifact.meta.at(key));
}
out << "},\"payload_hex\":" << quote_json(bytes_to_hex(artifact.payload)) << "}";
return out.str();
}
double audio_duration_ms(const MetricsAudioView & audio) {
if (audio.sample_rate <= 0 || audio.channels <= 0) {
return 0.0;
}
return 1000.0 * static_cast<double>(audio.samples) /
static_cast<double>(audio.sample_rate * audio.channels);
}
std::optional<MetricsAudioView> select_metrics_audio(
const engine::runtime::TaskResult & result,
const std::optional<AudioMetricsInfo> & input_audio) {
if (result.audio_output.has_value()) {
const auto & audio = *result.audio_output;
return MetricsAudioView{audio.sample_rate, audio.channels, audio.samples.size(), "output"};
}
if (result.named_audio_outputs.size() == 1) {
const auto & audio = result.named_audio_outputs.front().audio;
return MetricsAudioView{audio.sample_rate, audio.channels, audio.samples.size(), "output"};
}
if (input_audio.has_value()) {
return MetricsAudioView{input_audio->sample_rate, input_audio->channels, input_audio->samples, "input"};
}
return std::nullopt;
}
std::optional<std::filesystem::path> suffixed_json_path(
const std::optional<std::filesystem::path> & base,
const std::string & request_id) {
if (!base.has_value()) {
return std::nullopt;
}
return base->parent_path() / (base->stem().string() + "_" + request_id + base->extension().string());
}
// FIFOs, /dev/stdout and other char/socket special files can't go through
// the temp-file + rename dance below: rename() would unlink the special
// file and replace it with a plain regular file, so nothing ever reaches
// whatever has the other end open (e.g. `aplay` reading from a pipe).
// WavSink::write() itself is a single sequential pass with no seeking
// (the RIFF/data sizes are computed up front from the in-memory buffer),
// so it's safe to write straight to a special file.
bool is_special_output_target(const std::filesystem::path & path) {
std::error_code ec;
const auto status = std::filesystem::status(path, ec);
if (ec) {
return false;
}
switch (status.type()) {
case std::filesystem::file_type::fifo:
case std::filesystem::file_type::character:
case std::filesystem::file_type::socket:
return true;
default:
return false;
}
}
void write_wav_output(
const std::filesystem::path & path,
const engine::audio::AudioBuffer & audio,
const engine::audio::WavWriteOptions & wav_options) {
if (is_special_output_target(path)) {
engine::audio::WavSink(wav_options).write(path, audio);
return;
}
if (!path.parent_path().empty()) {
std::filesystem::create_directories(path.parent_path());
}
const auto tmp = path.parent_path() / (path.filename().string() + ".tmp");
std::filesystem::remove(tmp);
engine::audio::WavSink(wav_options).write(tmp, audio);
if (std::filesystem::exists(path)) {
std::filesystem::remove(path);
}
std::filesystem::rename(tmp, path);
}
std::string artifact_extension(const engine::runtime::VoiceArtifact & artifact) {
if (artifact.kind == engine::runtime::ArtifactKind::Midi) {
return ".mid";
}
const auto it = artifact.meta.find("extension");
if (it != artifact.meta.end() && !it->second.empty()) {
std::string ext = it->second;
if (ext.front() != '.') {
ext.insert(ext.begin(), '.');
}
return ext;
}
return ".json";
}
bool artifact_is_json_payload(const engine::runtime::VoiceArtifact & artifact) {
const auto it = artifact.meta.find("mime");
if (it == artifact.meta.end() || it->second.empty()) {
return true;
}
return it->second.rfind("application/json", 0) == 0;
}
void write_artifact_output(
const std::filesystem::path & path,
const engine::runtime::VoiceArtifact & artifact) {
if (!path.parent_path().empty()) {
std::filesystem::create_directories(path.parent_path());
}
const auto tmp = path.parent_path() / (path.filename().string() + ".tmp");
std::filesystem::remove(tmp);
std::ofstream output(tmp, std::ios::binary);
if (!output) {
throw std::runtime_error("failed to open artifact output: " + tmp.string());
}
output.write(
reinterpret_cast<const char *>(artifact.payload.data()),
static_cast<std::streamsize>(artifact.payload.size()));
if (!output) {
throw std::runtime_error("failed to write artifact output: " + tmp.string());
}
output.close();
if (std::filesystem::exists(path)) {
std::filesystem::remove(path);
}
std::filesystem::rename(tmp, path);
}
std::string batch_manifest_to_json(const AppBatchResult & batch) {
std::ostringstream out;
out << "{\"requests\":[";
for (size_t i = 0; i < batch.results.size(); ++i) {
if (i != 0) {
out << ",";
}
const auto & item = batch.results[i];
out << "{\"id\":" << quote_json(item.id);
if (item.result.custom_schema_output.has_value()) {
out << ",\"custom_schema_output\":{\"schema\":" << quote_json(item.result.custom_schema_output->schema)
<< ",\"data\":" << engine::io::json::stringify(item.result.custom_schema_output->data) << "}";
}
if (item.result.audio_output.has_value()) {
out << ",\"sample_rate\":" << item.result.audio_output->sample_rate
<< ",\"channels\":" << item.result.audio_output->channels
<< ",\"samples\":" << item.result.audio_output->samples.size();
}
out << "}";
}
out << "],\"chapters\":[";
for (size_t i = 0; i < batch.chapters.size(); ++i) {
if (i != 0) {
out << ",";
}
out << "{\"id\":" << quote_json(batch.chapters[i].id)
<< ",\"start_sample\":" << batch.chapters[i].start_sample
<< ",\"end_sample\":" << batch.chapters[i].end_sample
<< "}";
}
out << "]}";
return out.str();
}
} // namespace
std::string word_timestamps_to_json(const std::vector<engine::runtime::WordTimestamp> & words) {
std::ostringstream out;
out << "[";
for (size_t i = 0; i < words.size(); ++i) {
if (i != 0) {
out << ",";
}
out << "{\"start_sample\":" << words[i].span.start_sample
<< ",\"end_sample\":" << words[i].span.end_sample
<< ",\"word\":" << quote_json(words[i].word)
<< ",\"confidence\":" << words[i].confidence
<< "}";
}
out << "]";
return out.str();
}
std::string safe_output_name(const std::string & value) {
std::string out;
out.reserve(value.size());
for (const char ch : value) {
const unsigned char uch = static_cast<unsigned char>(ch);
out.push_back(std::isalnum(uch) != 0 || ch == '-' || ch == '_' ? ch : '_');
}
return out.empty() ? "request" : out;
}
void emit_task_metrics(
const engine::runtime::TaskResult & result,
const std::optional<AudioMetricsInfo> & input_audio,
double wall_ms,
const std::string & prefix) {
std::cout << prefix << ".wall_ms=" << wall_ms << "\n";
// Process-wide high-water marks, so in a batch every item reports the
// peak reached so far, not its own.
const auto peak = engine::core::process_memory_peak();
if (peak.rss_bytes > 0) {
std::cout << prefix << ".memory.peak_rss_mb=" << (peak.rss_bytes >> 20) << "\n";
}
if (peak.footprint_bytes > 0) {
std::cout << prefix << ".memory.peak_footprint_mb=" << (peak.footprint_bytes >> 20) << "\n";
std::cout << prefix << ".memory.peak_footprint_source=" << peak.footprint_source << "\n";
}
const auto audio = select_metrics_audio(result, input_audio);
if (!audio.has_value()) {
return;
}
const double duration_ms = audio_duration_ms(*audio);
std::cout << prefix << ".audio_duration_ms=" << duration_ms << "\n";
if (duration_ms > 0.0) {
const double rtf = wall_ms / duration_ms;
std::cout << prefix << ".rtf=" << rtf << "\n";
if (rtf > 0.0) {
std::cout << prefix << ".x_realtime=" << (1.0 / rtf) << "\n";
}
}
std::cout << prefix << ".audio_duration_source=" << audio->source << "\n";
std::cout << prefix << ".sample_rate=" << audio->sample_rate << "\n";
std::cout << prefix << ".channels=" << audio->channels << "\n";
}
void emit_task_result(
const engine::runtime::TaskResult & result,
const std::optional<std::filesystem::path> & audio_out,
const std::optional<std::filesystem::path> & audio_out_dir,
const std::optional<std::filesystem::path> & artifact_out_dir,
const std::optional<std::filesystem::path> & segments_out,
const std::optional<std::filesystem::path> & turns_out,
const std::optional<std::filesystem::path> & words_out,
const engine::audio::WavWriteOptions & wav_options) {
if (result.audio_output.has_value() && audio_out.has_value()) {
write_wav_output(*audio_out, engine::audio::AudioBuffer{
result.audio_output->sample_rate,
result.audio_output->channels,
result.audio_output->samples,
}, wav_options);
std::cout << "audio_out=" << audio_out->string() << "\n";
} else if (!result.named_audio_outputs.empty() && audio_out.has_value()) {
if (result.named_audio_outputs.size() != 1) {
throw std::runtime_error("--out requires exactly one audio output");
}
const auto & audio = result.named_audio_outputs.front().audio;
write_wav_output(*audio_out, engine::audio::AudioBuffer{
audio.sample_rate,
audio.channels,
audio.samples,
}, wav_options);
std::cout << "audio_out=" << audio_out->string() << "\n";
} else if (result.artifact_output.has_value() && audio_out.has_value()) {
write_artifact_output(*audio_out, *result.artifact_output);
std::cout << "artifact_out=" << audio_out->string() << "\n";
}
if (!result.named_audio_outputs.empty()) {
if (audio_out_dir.has_value()) {
std::filesystem::create_directories(*audio_out_dir);
for (const auto & output : result.named_audio_outputs) {
const auto path = *audio_out_dir / (output.id + ".wav");
write_wav_output(path, engine::audio::AudioBuffer{
output.audio.sample_rate,
output.audio.channels,
output.audio.samples,
}, wav_options);
std::cout << "audio_out[" << output.id << "]=" << path.string() << "\n";
}
} else {
std::cout << "audio_outputs=" << result.named_audio_outputs.size() << "\n";
for (const auto & output : result.named_audio_outputs) {
std::cout << "audio_output_id=" << output.id
<< " sample_rate=" << output.audio.sample_rate
<< " channels=" << output.audio.channels
<< " samples=" << output.audio.samples.size()
<< "\n";
}
}
}
if (!result.speech_segments.empty()) {
const auto json = speech_segments_to_json(result.speech_segments);
if (segments_out.has_value()) {
if (!segments_out->parent_path().empty()) {
std::filesystem::create_directories(segments_out->parent_path());
}
std::ofstream(*segments_out) << json;
std::cout << "segments_out=" << segments_out->string() << "\n";
} else {
std::cout << "speech_segments=" << json << "\n";
}
}
if (!result.speaker_turns.empty()) {
const auto json = speaker_turns_to_json(result.speaker_turns);
if (turns_out.has_value()) {
if (!turns_out->parent_path().empty()) {
std::filesystem::create_directories(turns_out->parent_path());
}
std::ofstream(*turns_out) << json;
std::cout << "turns_out=" << turns_out->string() << "\n";
} else {
std::cout << "speaker_turns=" << json << "\n";
}
}
if (!result.word_timestamps.empty()) {
const auto json = word_timestamps_to_json(result.word_timestamps);
if (words_out.has_value()) {
if (!words_out->parent_path().empty()) {
std::filesystem::create_directories(words_out->parent_path());
}
std::ofstream(*words_out) << json;
std::cout << "words_out=" << words_out->string() << "\n";
} else {
std::cout << "word_timestamps=" << json << "\n";
}
}
if (result.text_output.has_value()) {
std::cout << "text_output=" << result.text_output->text << "\n";
}
if (result.custom_schema_output.has_value()) {
std::cout << "custom_schema_output={\"schema\":" << quote_json(result.custom_schema_output->schema)
<< ",\"data\":" << engine::io::json::stringify(result.custom_schema_output->data)
<< "}\n";
}
if (result.artifact_output.has_value()) {
const auto & artifact = *result.artifact_output;
std::cout << "artifact=" << artifact.id
<< " kind=" << artifact_kind_name(artifact.kind)
<< " bytes=" << artifact.payload.size() << "\n";
if (artifact_out_dir.has_value()) {
std::filesystem::create_directories(*artifact_out_dir);
const auto path = *artifact_out_dir / (safe_output_name(artifact.id) + artifact_extension(artifact));
write_artifact_output(path, artifact);
std::cout << "artifact_out[" << artifact.id << "]=" << path.string() << "\n";
}
}
if (!result.output_artifacts.empty()) {
std::cout << "artifacts=" << result.output_artifacts.size() << "\n";
for (const auto & artifact : result.output_artifacts) {
std::cout << "artifact=" << artifact.id
<< " kind=" << artifact_kind_name(artifact.kind)
<< " bytes=" << artifact.payload.size() << "\n";
if (artifact_out_dir.has_value()) {
std::filesystem::create_directories(*artifact_out_dir);
const auto path = *artifact_out_dir / (safe_output_name(artifact.id) + artifact_extension(artifact));
if (artifact.kind == engine::runtime::ArtifactKind::Midi ||
!artifact_is_json_payload(artifact)) {
write_artifact_output(path, artifact);
} else {
std::ofstream(path) << artifact_to_json(artifact) << "\n";
}
std::cout << "artifact_out[" << artifact.id << "]=" << path.string() << "\n";
}
}
}
}
void emit_batch_result(
const AppBatchResult & batch,
const FileOutputPolicy & policy) {
emit_batch_summary(batch, policy);
for (size_t i = 0; i < batch.results.size(); ++i) {
emit_batch_item_result(i, batch.results[i], policy);
}
std::cout << "[TIMING] session.prepare_ms " << batch.prepare_ms << "\n";
std::cout << "[TIMING] session.wall_ms " << batch.session_wall_ms << "\n";
}
void emit_batch_summary(
const AppBatchResult & batch,
const FileOutputPolicy & policy) {
if (batch.merged_audio.has_value()) {
if (!policy.audio_out.has_value()) {
throw std::runtime_error("merged batch audio requires --out");
}
write_wav_output(*policy.audio_out, engine::audio::AudioBuffer{
batch.merged_audio->sample_rate,
batch.merged_audio->channels,
batch.merged_audio->samples,
}, policy.wav_options);
std::cout << "merged_audio_out=" << policy.audio_out->string() << "\n";
}
if (policy.batch_manifest_out.has_value()) {
if (!policy.batch_manifest_out->parent_path().empty()) {
std::filesystem::create_directories(policy.batch_manifest_out->parent_path());
}
std::ofstream(*policy.batch_manifest_out) << batch_manifest_to_json(batch) << "\n";
std::cout << "batch_manifest_out=" << policy.batch_manifest_out->string() << "\n";
}
}
void emit_batch_item_result(
size_t index,
const AppRequestResult & item,
const FileOutputPolicy & policy) {
const std::string request_id = safe_output_name(item.id);
std::cout << "request_index=" << index << "\n";
std::cout << "request_id=" << request_id << "\n";
std::cout << "[TIMING] request." << request_id << ".wall_ms " << item.wall_ms << "\n";
std::optional<std::filesystem::path> audio_out;
std::optional<std::filesystem::path> named_out_dir;
std::optional<std::filesystem::path> artifact_out_dir;
if (policy.output_dir.has_value()) {
if (item.result.audio_output.has_value()) {
audio_out = *policy.output_dir / (request_id + ".wav");
}
if (!item.result.named_audio_outputs.empty()) {
named_out_dir = *policy.output_dir / request_id;
}
if (item.result.artifact_output.has_value()) {
audio_out = *policy.output_dir / (request_id + artifact_extension(*item.result.artifact_output));
}
if (!item.result.output_artifacts.empty()) {
artifact_out_dir = *policy.output_dir / request_id;
}
}
emit_task_result(
item.result,
audio_out,
named_out_dir,
artifact_out_dir,
suffixed_json_path(policy.segments_base, request_id),
suffixed_json_path(policy.turns_base, request_id),
suffixed_json_path(policy.words_base, request_id),
policy.wav_options);
}
} // namespace minitts::app

58
app/workflow/file_sink.h Normal file
View file

@ -0,0 +1,58 @@
#pragma once
#include "execution.h"
#include "engine/framework/audio/wav_writer.h"
#include <cstddef>
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
namespace minitts::app {
struct AudioMetricsInfo {
int sample_rate = 0;
int channels = 0;
size_t samples = 0;
};
struct FileOutputPolicy {
std::optional<std::filesystem::path> audio_out;
std::optional<std::filesystem::path> output_dir;
std::optional<std::filesystem::path> segments_base;
std::optional<std::filesystem::path> turns_base;
std::optional<std::filesystem::path> words_base;
std::optional<std::filesystem::path> batch_manifest_out;
engine::audio::WavWriteOptions wav_options{};
};
std::string safe_output_name(const std::string & value);
std::string word_timestamps_to_json(const std::vector<engine::runtime::WordTimestamp> & words);
void emit_task_result(
const engine::runtime::TaskResult & result,
const std::optional<std::filesystem::path> & audio_out,
const std::optional<std::filesystem::path> & audio_out_dir,
const std::optional<std::filesystem::path> & artifact_out_dir,
const std::optional<std::filesystem::path> & segments_out,
const std::optional<std::filesystem::path> & turns_out,
const std::optional<std::filesystem::path> & words_out,
const engine::audio::WavWriteOptions & wav_options = {});
void emit_task_metrics(
const engine::runtime::TaskResult & result,
const std::optional<AudioMetricsInfo> & input_audio,
double wall_ms,
const std::string & prefix = "metrics");
void emit_batch_item_result(
size_t index,
const AppRequestResult & item,
const FileOutputPolicy & policy);
void emit_batch_summary(
const AppBatchResult & batch,
const FileOutputPolicy & policy);
void emit_batch_result(
const AppBatchResult & batch,
const FileOutputPolicy & policy);
} // namespace minitts::app

44
app/workflow/pipeline.cpp Normal file
View file

@ -0,0 +1,44 @@
#include "pipeline.h"
#include <algorithm>
#include <stdexcept>
namespace minitts::app {
void AppPipelineRegistry::add(std::unique_ptr<IAppPipeline> pipeline) {
if (pipeline == nullptr) {
throw std::runtime_error("cannot register a null app pipeline");
}
const std::string id = pipeline->id();
if (id.empty()) {
throw std::runtime_error("app pipeline id must not be empty");
}
const auto [_, inserted] = pipelines_.emplace(id, std::move(pipeline));
if (!inserted) {
throw std::runtime_error("duplicate app pipeline id: " + id);
}
}
const IAppPipeline & AppPipelineRegistry::require(std::string_view id) const {
const auto it = pipelines_.find(std::string(id));
if (it == pipelines_.end()) {
throw std::runtime_error("unknown app pipeline: " + std::string(id));
}
return *it->second;
}
std::vector<std::string> AppPipelineRegistry::ids() const {
std::vector<std::string> out;
out.reserve(pipelines_.size());
for (const auto & [id, _] : pipelines_) {
out.push_back(id);
}
std::sort(out.begin(), out.end());
return out;
}
AppPipelineRegistry make_default_pipeline_registry() {
return AppPipelineRegistry{};
}
} // namespace minitts::app

Some files were not shown because too many files have changed in this diff Show more