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. # # WHAT THAT BUYS IS ONE PROTOBUF RUNTIME, not an executable free of protobuf # symbols, and the difference matters to whoever checks this next. Measured with # nm -C --defined-only on the linked grpc-server, 2515 google::protobuf:: # symbols are still DEFINED in it, and that is what should be there: they are # generated code, sentencepiece::ModelProto's own _InternalParse and # CheckTypeAndMergeFrom among them, which name protobuf types in their # signatures and are compiled into every user of a .proto. Expecting zero would # send a reader looking for a regression that is not one. # # The claim that decides whether the ABI mismatch above is gone is the RUNTIME # one, and it holds: google::protobuf::internal::ParseContext::ParseMessage is # UNDEFINED in the executable, so every generated _InternalParse resolves it to # libprotobuf.so at load instead of to a vendored 3.14 copy. No vendored # protobuf-lite archive is pulled in at all, and citrinet_asr, which parses a # SentencePiece ModelProto at load, tokenizes correctly as a result. 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 stem_selection.cpp generation_request.cpp stream_delta.cpp wav_header.cpp inference_lane.cpp live_watchdog.cpp ) # Two files carry a switch over an enum with no `default:` label, deliberately, # so that -Wswitch reports an enumerator nobody handled. -Wswitch is only a # warning by default, and a warning in a 600-file build log is a warning nobody # reads, so it is promoted to an error on exactly these two translation units. # Not project-wide: upstream's own sources are not held to this, and they are # where the churn is. # # 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. This is the difference # between a build failure and a backend that silently runs the wrong task. # # capability_routing.cpp's unsupported_surface() switches UnsupportedRpc onto the # row of unsupported_surfaces() that explains it. Left as a warning, a sixth # enumerator added without a row BUILDS AND SHIPS, and its trailing # `return surfaces[0];` then answers the new RPC with AudioEncode's codec reason: # a confident, specific and false statement about audio.cpp, on the wire, on the # one code path whose entire job is to be truthful about what this backend # cannot do. Verified rather than assumed: adding a sixth enumerator and building # the shipping target produced exit 0, a binary, and one warning. A compile-time # check is the better trade than the runtime fallback it replaced only if it is # fatal, so here it is fatal. if(NOT MSVC) set_source_files_properties(loaded_model.cpp capability_routing.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) # Same shape as result_map_ctest: generation_request touches only the plain # structs in engine/framework/runtime/session.h plus the generated protobuf # messages, so the headers are all it needs and no engine_runtime is linked. add_executable(generation_request_ctest generation_request_ctest.cpp generation_request.cpp) target_include_directories(generation_request_ctest PRIVATE "${AUDIO_CPP_DIR}/include" "${CMAKE_CURRENT_SOURCE_DIR}" # session.h reaches ggml.h through core/backend.h, and this target links # no ggml, so it has to name the include directory itself. "${AUDIO_CPP_DIR}/external/ggml/include") target_link_libraries(generation_request_ctest PRIVATE hw_grpc_proto protobuf::libprotobuf Threads::Threads) target_compile_options(generation_request_ctest PRIVATE -Wall -Wextra -Wpedantic) add_test(NAME generation_request COMMAND generation_request_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) # The streaming drivers live in loaded_model.cpp, which links the engine, so # this cannot be a standalone *_test.cpp. It builds no model and reads no # file: LoadedModel::Session is a plain struct holding a pointer to an # engine interface, so the drivers are exercised against fake sessions. add_executable(streaming_driver_ctest streaming_driver_ctest.cpp loaded_model.cpp capability_routing.cpp family_gate.cpp model_options.cpp inference_lane.cpp) target_include_directories(streaming_driver_ctest PRIVATE "${AUDIO_CPP_DIR}/include" "${CMAKE_CURRENT_SOURCE_DIR}") target_link_libraries(streaming_driver_ctest PRIVATE engine_runtime ggml Threads::Threads) target_compile_options(streaming_driver_ctest PRIVATE -Wall -Wextra -Wpedantic) # No "$ORIGIN" rpath override here, unlike the shipping target and unlike # audio_io_ctest. loaded_model.cpp reaches make_default_registry, so this # binary genuinely links libggml, and CMake's own build-tree rpath is what # finds it: the ggml shared objects land in ${CMAKE_CURRENT_BINARY_DIR}/bin # while the test binary sits one directory up. A build-host absolute path in # a test binary is harmless, since package.sh ships only grpc-server, and # forcing "$ORIGIN" here means ctest cannot start the binary at all. add_test(NAME streaming_driver COMMAND streaming_driver_ctest) # Asserts that the upstream ABSENCES capability_routing.cpp's refusal # messages rest on are still absences, by querying make_default_registry() # rather than by re-reading upstream. This is what makes an AUDIO_CPP_VERSION # bump that adds a codec task kind, an spk family or a streaming converter # fail the build instead of leaving a false statement on the wire. # # It links engine_runtime purely to run that query, which is why it lives # here rather than with the standalone *_test.cpp files, and it needs no # "$ORIGIN" rpath override for the same reason streaming_driver_ctest does # not: see the note above. add_executable(upstream_absence_ctest upstream_absence_ctest.cpp) target_include_directories(upstream_absence_ctest PRIVATE "${AUDIO_CPP_DIR}/include" "${CMAKE_CURRENT_SOURCE_DIR}") target_link_libraries(upstream_absence_ctest PRIVATE engine_runtime ggml Threads::Threads) target_compile_options(upstream_absence_ctest PRIVATE -Wall -Wextra -Wpedantic) add_test(NAME upstream_absence COMMAND upstream_absence_ctest) endif()