mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-01 02:49:51 -04:00
backend(audio-cpp): convert sample, time and PCM units
Integer nanosecond conversion so 44.1 kHz stays exact, float seconds for the VAD and diarization messages, and saturating s16le encode so an overshooting sample cannot wrap to the opposite sign. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
@@ -88,6 +88,7 @@ add_executable(${TARGET}
|
||||
grpc-server.cpp
|
||||
model_options.cpp
|
||||
capability_routing.cpp
|
||||
audio_units.cpp
|
||||
)
|
||||
|
||||
target_include_directories(${TARGET} PRIVATE
|
||||
|
||||
66
backend/cpp/audio-cpp/audio_units.cpp
Normal file
66
backend/cpp/audio-cpp/audio_units.cpp
Normal file
@@ -0,0 +1,66 @@
|
||||
#include "audio_units.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
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<std::int64_t>(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<float>(static_cast<double>(samples) /
|
||||
static_cast<double>(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<std::int64_t>(seconds * static_cast<double>(sample_rate));
|
||||
}
|
||||
|
||||
std::vector<float> s16le_to_f32(const std::string &bytes) {
|
||||
std::vector<float> samples;
|
||||
const size_t count = bytes.size() / 2;
|
||||
samples.reserve(count);
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
const auto low = static_cast<unsigned char>(bytes[i * 2]);
|
||||
const auto high = static_cast<unsigned char>(bytes[i * 2 + 1]);
|
||||
const auto raw = static_cast<std::int16_t>(
|
||||
static_cast<std::uint16_t>(low) |
|
||||
(static_cast<std::uint16_t>(high) << 8));
|
||||
samples.push_back(static_cast<float>(raw) / 32768.0f);
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
std::string f32_to_s16le(const std::vector<float> &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::int16_t>(std::lround(clamped * 32767.0f));
|
||||
const auto raw = static_cast<std::uint16_t>(value);
|
||||
bytes.push_back(static_cast<char>(raw & 0xFF));
|
||||
bytes.push_back(static_cast<char>((raw >> 8) & 0xFF));
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
} // namespace audiocpp_backend
|
||||
33
backend/cpp/audio-cpp/audio_units.h
Normal file
33
backend/cpp/audio-cpp/audio_units.h
Normal file
@@ -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 <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<float> 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<float> &samples);
|
||||
|
||||
} // namespace audiocpp_backend
|
||||
118
backend/cpp/audio-cpp/audio_units_test.cpp
Normal file
118
backend/cpp/audio-cpp/audio_units_test.cpp
Normal file
@@ -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 <cmath>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<float> 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<float> 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<unsigned char>(encoded[0]) == 0x00, "low byte first");
|
||||
check(static_cast<unsigned char>(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<float> 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;
|
||||
}
|
||||
Reference in New Issue
Block a user