Files
LocalAI/backend/cpp/audio-cpp/CMakeLists.txt
Ettore Di Giacinto bc6fd03a4b backend(audio-cpp): fix the segment text a transcription response is built from
Segment text is not decoration. core/http/endpoints/openai/transcription.go
routes response_format text, srt, vtt and lrc through
schema.TranscriptionResponse, which builds the entire body out of
Segments[].Text and never reads the top-level text. So for those four
formats the segment text IS the response.

nemotron_asr emits one word_timestamp per SentencePiece token, and the
word boundary is carried as a LEADING SPACE on the piece ("So", "me",
" call"). join_words inserted a space unconditionally, so
response_format=text returned "So me  call   me  na ture ," while the
correct sentence sat unread in the top-level field. The separator is now
chosen from the words themselves: whole words are space-joined, subword
pieces are concatenated, and one leading space anywhere selects the
latter. Concatenating the real nemotron pieces reproduces text_output
exactly, verified end to end.

This does not touch the top-level text, which is still text_output
verbatim. The rule that forbids deriving the transcript from the segments
is about the direction segments -> text; segment text has no source other
than its words.

Two smaller corrections in the same area:

timestamp_granularities ["word"] set only "word_timestamps", a key no
family in the pinned upstream reads. It now sets "return_timestamps",
which qwen3_asr does read and which both runs its forced aligner and
shortens its chunk window, so asking for word granularity no longer
silently returns nothing.

The request-option comment claimed more than it delivered. prompt,
translate and temperature are read by no ASR family, and are forwarded
only so a family adopting them works unchanged; the comment now says so
per key, and gives TranscriptRequest.diarize the same explicit treatment
threads already had.

Also: the shipping target now carries -Wall -Wextra -Wpedantic, which it
never did, so "the build is clean" starts meaning something; and
fill_transcript_result no longer swallows a null response pointer, since
answering OK with an empty transcript is the one failure mode this unit
exists to prevent.

Assisted-by: Claude:claude-opus-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-26 16:51:07 +00:00

230 lines
10 KiB
CMake

cmake_minimum_required(VERSION 3.20)
project(audio-cpp-grpc-server LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(TARGET grpc-server)
set(AUDIO_CPP_DIR "${CMAKE_CURRENT_SOURCE_DIR}/audio.cpp"
CACHE PATH "Path to the pinned audio.cpp checkout")
option(AUDIO_CPP_GRPC_BUILD_TESTS "Build engine-linked ctest binaries" OFF)
if(NOT EXISTS "${AUDIO_CPP_DIR}/CMakeLists.txt")
message(FATAL_ERROR
"AUDIO_CPP_DIR does not contain an audio.cpp checkout: ${AUDIO_CPP_DIR}. "
"Run 'make audio.cpp' first.")
endif()
if(APPLE)
# Homebrew installs protobuf/grpc under a non-default prefix.
if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "arm64")
set(HOMEBREW_DEFAULT_PREFIX "/opt/homebrew")
else()
set(HOMEBREW_DEFAULT_PREFIX "/usr/local")
endif()
link_directories("${HOMEBREW_DEFAULT_PREFIX}/lib")
include_directories("${HOMEBREW_DEFAULT_PREFIX}/include")
endif()
find_package(Threads REQUIRED)
find_package(Protobuf CONFIG QUIET)
if(NOT Protobuf_FOUND)
find_package(Protobuf REQUIRED)
endif()
find_package(gRPC CONFIG QUIET)
if(NOT gRPC_FOUND)
# Reached only on distros whose grpc++ packaging ships no CMake config.
# Ubuntu's libgrpc-dev does ship one, so this is dead code on LocalAI's own
# build distro. Kept for the distros that do not.
find_library(GRPCPP_LIB grpc++ REQUIRED)
find_library(GRPCPP_REFLECTION_LIB grpc++_reflection REQUIRED)
add_library(gRPC::grpc++ INTERFACE IMPORTED)
set_target_properties(gRPC::grpc++ PROPERTIES
INTERFACE_LINK_LIBRARIES "${GRPCPP_LIB}")
add_library(gRPC::grpc++_reflection INTERFACE IMPORTED)
set_target_properties(gRPC::grpc++_reflection PROPERTIES
INTERFACE_LINK_LIBRARIES "${GRPCPP_REFLECTION_LIB}")
endif()
find_program(_PROTOC NAMES protoc REQUIRED)
find_program(_GRPC_CPP_PLUGIN NAMES grpc_cpp_plugin REQUIRED)
get_filename_component(HW_PROTO "${CMAKE_CURRENT_SOURCE_DIR}/../../backend.proto" ABSOLUTE)
get_filename_component(HW_PROTO_PATH "${HW_PROTO}" PATH)
set(HW_PROTO_SRCS "${CMAKE_CURRENT_BINARY_DIR}/backend.pb.cc")
set(HW_PROTO_HDRS "${CMAKE_CURRENT_BINARY_DIR}/backend.pb.h")
set(HW_GRPC_SRCS "${CMAKE_CURRENT_BINARY_DIR}/backend.grpc.pb.cc")
set(HW_GRPC_HDRS "${CMAKE_CURRENT_BINARY_DIR}/backend.grpc.pb.h")
add_custom_command(
OUTPUT "${HW_PROTO_SRCS}" "${HW_PROTO_HDRS}" "${HW_GRPC_SRCS}" "${HW_GRPC_HDRS}"
COMMAND ${_PROTOC}
ARGS --grpc_out "${CMAKE_CURRENT_BINARY_DIR}"
--cpp_out "${CMAKE_CURRENT_BINARY_DIR}"
-I "${HW_PROTO_PATH}"
--plugin=protoc-gen-grpc="${_GRPC_CPP_PLUGIN}"
"${HW_PROTO}"
DEPENDS "${HW_PROTO}")
add_library(hw_grpc_proto STATIC
${HW_GRPC_SRCS} ${HW_GRPC_HDRS}
${HW_PROTO_SRCS} ${HW_PROTO_HDRS})
target_include_directories(hw_grpc_proto PUBLIC ${CMAKE_CURRENT_BINARY_DIR})
# Required on macOS: without these the Homebrew protobuf/grpc include dirs never
# reach this target and google/protobuf/runtime_version.h is not found.
target_link_libraries(hw_grpc_proto PUBLIC protobuf::libprotobuf gRPC::grpc++)
# TWO PROTOBUF RUNTIMES IN ONE BINARY, AND THE ONE THAT WON WAS THE WRONG ONE.
#
# engine_runtime links sentencepiece, whose default SPM_PROTOBUF_PROVIDER
# ("internal") builds the protobuf-lite 3.14.0 sources vendored under
# external/sentencepiece/third_party/protobuf-lite. Our generated backend.pb.cc
# is compiled against the toolchain's protobuf 3.21.12 headers and links
# libprotobuf.so 3.21.12. Both used to end up in the executable: 476
# google::protobuf:: symbols from that archive, 278 of them also defined by
# libprotobuf.so.
#
# The binding is decided at STATIC LINK time. Once ld pulls a sentencepiece
# member in for sentencepiece's own code, that member's protobuf definitions are
# in the executable and references from libhw_grpc_proto.a bind to them. Do NOT
# reach for -Wl,--exclude-libs: it flips those symbols to LOCAL in .dynsym and
# the breakage is unchanged, because no visibility flag revisits a static
# binding already made.
#
# What broke, measured rather than assumed:
# google::protobuf::internal::ParseContext::ParseMessage(MessageLite*, const char*)
# is what every generated _InternalParse calls for a SUBMESSAGE field and for
# nothing else. Bound to the 3.14 definition it fails, so a flat message parsed
# and every nested one did not: a TranscriptResult carrying segments serialized
# to correct bytes that the same process could not read back, and
# TranscriptLiveRequest, a oneof of submessages, could not have been parsed at
# all. 3.21 generated code was also running 3.14 arena, ArenaStringPtr and
# ExtensionSet code, which is an ABI mismatch rather than a missing feature, so
# "not observed to bite yet" was never a reason to leave it.
#
# "package" makes sentencepiece use the protobuf found above, which is the one
# the generated code was built against. It must be set before add_subdirectory,
# since that is when sentencepiece's own cache entry is created. After it, zero
# google::protobuf:: definitions remain in the executable, and citrinet_asr,
# which parses a SentencePiece ModelProto at load, still tokenizes correctly.
set(SPM_PROTOBUF_PROVIDER "package" CACHE STRING
"Make sentencepiece use the found protobuf, not its vendored 3.14 copy" FORCE)
# Upstream's global add_compile_options(-Wall -Wextra -Wpedantic -pedantic-errors)
# is a directory property of the subdirectory and does not reach our targets.
#
# EXCLUDE_FROM_ALL is load-bearing, do not drop it: upstream's default target set
# includes its CLI, server, converter and test binaries, none of which we ship.
# Without it every build would compile all of them. The targets we do name in
# target_link_libraries below are still built on demand, so nothing is lost.
add_subdirectory("${AUDIO_CPP_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/audio-cpp" EXCLUDE_FROM_ALL)
add_executable(${TARGET}
grpc-server.cpp
model_options.cpp
capability_routing.cpp
family_gate.cpp
loaded_model.cpp
audio_io.cpp
audio_units.cpp
transcript_assembly.cpp
result_map.cpp
inference_lane.cpp
)
# loaded_model.cpp mirrors engine::runtime::VoiceTaskKind onto its own Task enum.
# Its static_asserts catch an insertion or a reorder, but an enumerator APPENDED
# after the last one shifts no value, so no assertion can see it. What does see
# it is from_engine_task's switch over the engine enum, which carries no
# `default:` label. -Wswitch is only a warning by default, and a warning in a
# 600-file build log is a warning nobody reads, so promote it here: this is the
# difference between a build failure and a backend that silently runs the wrong
# task.
if(NOT MSVC)
set_source_files_properties(loaded_model.cpp PROPERTIES
COMPILE_OPTIONS "-Werror=switch")
endif()
target_include_directories(${TARGET} PRIVATE
"${AUDIO_CPP_DIR}/include"
"${CMAKE_CURRENT_SOURCE_DIR}")
# The shipping binary is held to the same bar as the tests below. Upstream's own
# add_compile_options is a property of its directory and never reached this
# target, so until now "the build was clean" meant only that nothing was being
# checked.
if(NOT MSVC)
target_compile_options(${TARGET} PRIVATE -Wall -Wextra -Wpedantic)
endif()
target_link_libraries(${TARGET} PRIVATE
hw_grpc_proto
engine_runtime
ggml
gRPC::grpc++
gRPC::grpc++_reflection
protobuf::libprotobuf
Threads::Threads)
# ENGINE_ENABLE_CPU_ALL_VARIANTS builds ggml backends as shared objects that sit
# next to the binary in the package, so the binary must search its own directory.
# BUILD_WITH_INSTALL_RPATH keeps the build-tree binary at exactly "$ORIGIN".
# Upstream sets CMAKE_BUILD_WITH_INSTALL_RPATH in its own directory scope, which
# does not reach ours, so without this CMake also appends its build-tree library
# directory. That absolute build-host path would survive into the copied binary
# and let a package.sh that forgot to bundle libggml*.so still pass on the build
# machine while failing everywhere else.
set_target_properties(${TARGET} PROPERTIES
BUILD_RPATH "$ORIGIN"
INSTALL_RPATH "$ORIGIN"
BUILD_WITH_INSTALL_RPATH TRUE)
if(AUDIO_CPP_GRPC_BUILD_TESTS)
enable_testing()
# These are the units whose tests CANNOT run under
# backend/cpp/run-unit-tests.sh, because that script compiles each
# *_test.cpp standalone with no protobuf and no audio.cpp include path.
# They are named *_ctest.cpp so the script's glob does not pick them up and
# fail every backend's suite; everything that can be stdlib-only still is,
# and still lives in a *_test.cpp beside its unit.
add_executable(result_map_ctest
result_map_ctest.cpp
result_map.cpp
transcript_assembly.cpp
audio_units.cpp)
target_include_directories(result_map_ctest PRIVATE
"${AUDIO_CPP_DIR}/include"
"${CMAKE_CURRENT_SOURCE_DIR}"
# session.h reaches ggml.h through core/backend.h. Every other target
# here inherits that directory from the ggml target it links; this one
# links no ggml, so it has to name it.
"${AUDIO_CPP_DIR}/external/ggml/include")
# No engine_runtime: result_map touches only the plain structs in
# engine/framework/runtime/session.h, so the header is all it needs.
target_link_libraries(result_map_ctest PRIVATE
hw_grpc_proto
protobuf::libprotobuf
Threads::Threads)
target_compile_options(result_map_ctest PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME result_map COMMAND result_map_ctest)
add_executable(audio_io_ctest
audio_io_ctest.cpp
audio_io.cpp)
target_include_directories(audio_io_ctest PRIVATE
"${AUDIO_CPP_DIR}/include"
"${CMAKE_CURRENT_SOURCE_DIR}")
target_link_libraries(audio_io_ctest PRIVATE
engine_runtime
ggml
Threads::Threads)
target_compile_options(audio_io_ctest PRIVATE -Wall -Wextra -Wpedantic)
set_target_properties(audio_io_ctest PROPERTIES
BUILD_RPATH "$ORIGIN"
INSTALL_RPATH "$ORIGIN"
BUILD_WITH_INSTALL_RPATH TRUE)
add_test(NAME audio_io COMMAND audio_io_ctest)
endif()