diff --git a/backend/cpp/audio-cpp/CMakeLists.txt b/backend/cpp/audio-cpp/CMakeLists.txt index 53eb4eea6..729dc70e2 100644 --- a/backend/cpp/audio-cpp/CMakeLists.txt +++ b/backend/cpp/audio-cpp/CMakeLists.txt @@ -88,6 +88,7 @@ add_executable(${TARGET} grpc-server.cpp model_options.cpp capability_routing.cpp + audio_units.cpp ) target_include_directories(${TARGET} PRIVATE diff --git a/backend/cpp/audio-cpp/audio_units.cpp b/backend/cpp/audio-cpp/audio_units.cpp new file mode 100644 index 000000000..6e15cce33 --- /dev/null +++ b/backend/cpp/audio-cpp/audio_units.cpp @@ -0,0 +1,66 @@ +#include "audio_units.h" + +#include +#include + +namespace audiocpp_backend { + +std::int64_t samples_to_nanoseconds(std::int64_t samples, int sample_rate) { + if (sample_rate <= 0) { + return 0; + } + // Split into whole seconds plus a remainder so the intermediate product + // cannot overflow on long recordings, and so rates like 44100 stay exact. + const std::int64_t rate = static_cast(sample_rate); + const std::int64_t whole_seconds = samples / rate; + const std::int64_t remainder = samples % rate; + return whole_seconds * 1000000000LL + (remainder * 1000000000LL) / rate; +} + +float samples_to_seconds(std::int64_t samples, int sample_rate) { + if (sample_rate <= 0) { + return 0.0f; + } + return static_cast(static_cast(samples) / + static_cast(sample_rate)); +} + +std::int64_t seconds_to_samples(double seconds, int sample_rate) { + if (sample_rate <= 0 || seconds <= 0.0) { + return 0; + } + return static_cast(seconds * static_cast(sample_rate)); +} + +std::vector s16le_to_f32(const std::string &bytes) { + std::vector samples; + const size_t count = bytes.size() / 2; + samples.reserve(count); + for (size_t i = 0; i < count; ++i) { + const auto low = static_cast(bytes[i * 2]); + const auto high = static_cast(bytes[i * 2 + 1]); + const auto raw = static_cast( + static_cast(low) | + (static_cast(high) << 8)); + samples.push_back(static_cast(raw) / 32768.0f); + } + return samples; +} + +std::string f32_to_s16le(const std::vector &samples) { + std::string bytes; + bytes.reserve(samples.size() * 2); + for (const float sample : samples) { + const float clamped = std::max(-1.0f, std::min(1.0f, sample)); + // 32767 rather than 32768 so +1.0 saturates at INT16_MAX instead of + // overflowing to INT16_MIN. + const auto value = + static_cast(std::lround(clamped * 32767.0f)); + const auto raw = static_cast(value); + bytes.push_back(static_cast(raw & 0xFF)); + bytes.push_back(static_cast((raw >> 8) & 0xFF)); + } + return bytes; +} + +} // namespace audiocpp_backend diff --git a/backend/cpp/audio-cpp/audio_units.h b/backend/cpp/audio-cpp/audio_units.h new file mode 100644 index 000000000..23f716829 --- /dev/null +++ b/backend/cpp/audio-cpp/audio_units.h @@ -0,0 +1,33 @@ +#pragma once + +// Time and sample-format conversion between audio.cpp's runtime types (sample +// indices, float PCM) and LocalAI's proto types. Standard library only. +// +// LocalAI uses three different time units: +// TranscriptSegment / TranscriptWord start,end : int64 nanoseconds +// VADSegment start,end : float seconds +// DiarizeSegment start,end : float seconds + +#include +#include +#include + +namespace audiocpp_backend { + +// Returns 0 when sample_rate is not positive rather than dividing by zero. +// Uses integer arithmetic so 44.1 kHz does not lose precision. +std::int64_t samples_to_nanoseconds(std::int64_t samples, int sample_rate); + +float samples_to_seconds(std::int64_t samples, int sample_rate); + +// Negative seconds clamp to 0. +std::int64_t seconds_to_samples(double seconds, int sample_rate); + +// Decodes little-endian signed 16-bit PCM. A trailing odd byte is dropped. +std::vector s16le_to_f32(const std::string &bytes); + +// Encodes to little-endian signed 16-bit PCM, clamping to [-1, 1] first so an +// overshooting sample saturates instead of wrapping to the opposite sign. +std::string f32_to_s16le(const std::vector &samples); + +} // namespace audiocpp_backend diff --git a/backend/cpp/audio-cpp/audio_units_test.cpp b/backend/cpp/audio-cpp/audio_units_test.cpp new file mode 100644 index 000000000..3b56911a0 --- /dev/null +++ b/backend/cpp/audio-cpp/audio_units_test.cpp @@ -0,0 +1,118 @@ +// Unit tests for audio_units. Standard library only. The harness compiles this +// as a single translation unit, so the implementation is included directly. + +#include "audio_units.cpp" + +#include +#include +#include +#include + +static int failures = 0; + +static void check(bool ok, const std::string &name) { + if (!ok) { + failures++; + fprintf(stderr, "FAIL: %s\n", name.c_str()); + } else { + fprintf(stderr, "ok: %s\n", name.c_str()); + } +} + +static bool close_to(float a, float b, float tol) { return std::fabs(a - b) <= tol; } + +using namespace audiocpp_backend; + +static void test_nanoseconds() { + // LocalAI TranscriptSegment/TranscriptWord times are nanoseconds + // (Go reads them as time.Duration). + check(samples_to_nanoseconds(16000, 16000) == 1000000000LL, "1s at 16k is 1e9 ns"); + check(samples_to_nanoseconds(8000, 16000) == 500000000LL, "0.5s at 16k"); + check(samples_to_nanoseconds(0, 16000) == 0, "zero samples is zero ns"); + check(samples_to_nanoseconds(1000, 0) == 0, "zero sample rate yields zero, not UB"); + // 44.1 kHz must not lose precision to float arithmetic. + check(samples_to_nanoseconds(44100, 44100) == 1000000000LL, "1s at 44.1k"); + check(samples_to_nanoseconds(22050, 44100) == 500000000LL, "0.5s at 44.1k"); + // The cases above all land on values a float happens to hold exactly, so + // they do not actually rule float arithmetic out. These do: + // a fraction that does not divide evenly, and a duration whose magnitude + // exceeds a float's 24-bit mantissa at nanosecond resolution. + check(samples_to_nanoseconds(44099, 44100) == 999977324LL, + "44.1k fraction is exact, not rounded through a float"); + check(samples_to_nanoseconds(44100LL * 3600, 44100) == 3600000000000LL, + "one hour at 44.1k is exact to the nanosecond"); + // A naive samples * 1e9 would overflow int64 here; the split into whole + // seconds plus a remainder is what keeps this correct. + check(samples_to_nanoseconds(44100LL * 360000, 44100) == 360000000000000LL, + "100 hours at 44.1k does not overflow"); + // Double arithmetic is close enough to pass everything above, but still + // truncates this one a nanosecond short. Integer division does not. + check(samples_to_nanoseconds(4004, 8000) == 500500000LL, + "0.5005s at 8k is exact to the nanosecond"); +} + +static void test_seconds() { + check(close_to(samples_to_seconds(24000, 24000), 1.0f, 1e-6f), "1s at 24k"); + check(close_to(samples_to_seconds(12000, 24000), 0.5f, 1e-6f), "0.5s at 24k"); + check(close_to(samples_to_seconds(100, 0), 0.0f, 1e-6f), "zero sample rate is 0s"); + check(seconds_to_samples(1.0, 16000) == 16000, "1s to samples at 16k"); + check(seconds_to_samples(0.5, 16000) == 8000, "0.5s to samples at 16k"); + check(seconds_to_samples(1.0, 0) == 0, "zero sample rate yields zero samples"); + check(seconds_to_samples(-1.0, 16000) == 0, "negative seconds clamps to zero"); +} + +static void test_s16le_round_trip() { + const std::vector original = {0.0f, 0.5f, -0.5f, 1.0f, -1.0f}; + const std::string encoded = f32_to_s16le(original); + check(encoded.size() == original.size() * 2, "two bytes per sample"); + + const std::vector decoded = s16le_to_f32(encoded); + check(decoded.size() == original.size(), "round trip keeps the sample count"); + for (size_t i = 0; i < original.size(); ++i) { + // 16-bit quantisation: one LSB is ~3.05e-5. Guard the index so a short + // result reports a named failure instead of aborting the whole suite. + check(i < decoded.size() && close_to(decoded[i], original[i], 1e-4f), + "round trip preserves sample " + std::to_string(i)); + } +} + +static void test_s16le_endianness() { + // 0.5 encodes to 16384 = 0x4000, little endian is 0x00 0x40. + const std::string encoded = f32_to_s16le({0.5f}); + check(encoded.size() == 2, "one sample is two bytes"); + check(static_cast(encoded[0]) == 0x00, "low byte first"); + check(static_cast(encoded[1]) == 0x40, "high byte second"); +} + +static void test_s16le_clamping() { + // Values outside [-1, 1] must clamp, not wrap around to the opposite sign. + const std::string encoded = f32_to_s16le({2.0f, -2.0f}); + const std::vector decoded = s16le_to_f32(encoded); + check(decoded.size() == 2, "two samples survive clamping"); + check(decoded.size() > 0 && decoded[0] > 0.99f, + "positive overshoot clamps to full scale"); + check(decoded.size() > 1 && decoded[1] < -0.99f, + "negative overshoot clamps to full scale"); +} + +static void test_s16le_odd_length() { + // A truncated frame must drop the dangling byte rather than read past it. + const std::string odd(5, '\0'); + check(s16le_to_f32(odd).size() == 2, "odd byte count drops the trailing byte"); + check(s16le_to_f32(std::string()).empty(), "empty input yields no samples"); +} + +int main() { + test_nanoseconds(); + test_seconds(); + test_s16le_round_trip(); + test_s16le_endianness(); + test_s16le_clamping(); + test_s16le_odd_length(); + if (failures) { + fprintf(stderr, "%d check(s) failed\n", failures); + return 1; + } + fprintf(stderr, "all audio_units checks passed\n"); + return 0; +}