Files
LocalAI/backend/cpp/audio-cpp/audio_io.cpp
Ettore Di Giacinto b92eff90f1 backend(audio-cpp): refuse an impossible stem early, and stop blaming the caller for a failed write
Four fixes from the first review of the AudioTransform RPC.

check_can_serve now returns the resolved route, so params[stem] on a route that
is not source separation is refused from the route instead of after a full
inference: 11 ms rather than the 4.5 s a miocodec conversion costs, and far
worse on seed_vc or vevo2. The post-run refusal stays as the backstop for a
separation-routed family that returns no stems anyway. The typo'd-stem-name
case still needs the run, since no framework header publishes the stem names
before one.

Stem names carrying control bytes are refused. GGUF strings are length prefixed
and demucs reads its sources from JSON, so an embedded NUL survives to here:
two names differing only after the NUL are distinct std::strings, so the
duplicate check passes them, and then path::c_str() truncates both and they
open the same file. That is exactly the silent overwrite the duplicate check
exists to prevent, with the .wav lost as well.

A failed write is now INTERNAL rather than INVALID_ARGUMENT. The destination is
LocalAI's own generated-content directory, not anything the caller named, so a
full disk or a permission fault there is a server fault and is worth retrying,
which is the opposite of what INVALID_ARGUMENT tells a client. An empty output
path stays INVALID_ARGUMENT.

Two comment corrections and one clarification: the separators' required rate is
their checkpoint's declared samplerate rather than a hardcoded 44100, seed_vc
resamples with soxr and falls back to sinc-hann, and the "no files left behind"
guarantee covers a refused request, not a write that fails partway through the
loop.

Assisted-by: Claude:claude-opus-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-29 19:03:33 +00:00

125 lines
5.2 KiB
C++

#include "audio_io.h"
#include "loaded_model.h"
#include "engine/framework/audio/conversion.h"
#include "engine/framework/audio/wav_reader.h"
#include "engine/framework/audio/wav_writer.h"
#include <filesystem>
#include <string>
#include <utility>
namespace audiocpp_backend {
engine::runtime::AudioBuffer read_audio_file(const std::string &path,
int target_sample_rate) {
if (path.empty()) {
throw ConfigError("audio-cpp: no input audio path was supplied");
}
std::error_code ec;
const bool present = std::filesystem::exists(std::filesystem::path(path), ec);
if (ec) {
// exists() returning false with ec set does NOT mean the file is
// absent, it means the question could not be answered: most often a
// parent directory is not searchable. Reporting that as "does not
// exist" sends the operator after the file when the fault is the
// permissions on the directory above it.
throw ConfigError("audio-cpp: cannot stat input audio " + path + ": " +
ec.message());
}
if (!present) {
throw ConfigError("audio-cpp: input audio does not exist: " + path);
}
engine::audio::WavData wav;
try {
wav = engine::audio::read_wav_f32(std::filesystem::path(path));
} catch (const std::exception &err) {
throw ConfigError("audio-cpp: cannot read " + path +
" as WAV: " + err.what());
}
if (wav.sample_rate <= 0) {
throw ConfigError("audio-cpp: " + path +
" declares a non-positive sample rate; every "
"timestamp derived from it would be zero");
}
// AudioBuffer's own default is 1, and a reader that reports 0 channels
// still gave us an interleaving of one. Normalised before the conversion
// below rather than after, because mixdown_interleaved_to_mono_average
// throws on a non-positive channel count.
if (wav.channels <= 0) {
wav.channels = 1;
}
engine::runtime::AudioBuffer buffer;
if (target_sample_rate <= 0) {
buffer.sample_rate = wav.sample_rate;
buffer.channels = wav.channels;
buffer.samples = std::move(wav.samples);
return buffer;
}
buffer.sample_rate = target_sample_rate;
buffer.channels = 1;
try {
// A no-op copy when the rates already match, so the common 16 kHz
// upload pays only the mono mixdown it would have paid inside the
// family anyway.
buffer.samples =
engine::audio::convert_wav_to_mono_linear_resampled(wav, target_sample_rate);
} catch (const std::exception &err) {
// ConfigError, so this is INVALID_ARGUMENT rather than INTERNAL. What
// reaches here is a malformed input: a sample count that is not a whole
// number of frames is the realistic one, and it is the uploader's file
// that is truncated, not this backend that is broken.
throw ConfigError("audio-cpp: cannot resample " + path + " from " +
std::to_string(wav.sample_rate) + " Hz to " +
std::to_string(target_sample_rate) +
" Hz: " + err.what());
}
return buffer;
}
void write_audio_file(const std::string &path,
const engine::runtime::AudioBuffer &audio) {
if (path.empty()) {
throw ConfigError("audio-cpp: no output path was supplied");
}
const std::filesystem::path destination(path);
if (destination.has_parent_path()) {
// Best effort: a failure here shows up as a write failure below, with a
// message naming the file the caller actually asked for.
std::error_code ec;
std::filesystem::create_directories(destination.parent_path(), ec);
}
try {
engine::audio::write_pcm16_wav(destination, audio.sample_rate,
audio.channels > 0 ? audio.channels : 1,
audio.samples);
} catch (const std::exception &err) {
// NOT a ConfigError, and the distinction is not cosmetic. The
// destination is chosen by LocalAI rather than by the caller: it is a
// unique name inside GeneratedContentDir. A failure to write it is a
// full disk, a permission fault on the server's own directory, or a bad
// mount, none of which the caller can fix or is to blame for. As a
// ConfigError this surfaced as INVALID_ARGUMENT, which tells a client
// its request was wrong and not to retry; a plain runtime_error maps to
// INTERNAL, which is both true and retryable. The empty path above
// stays INVALID_ARGUMENT, because that one really is a malformed
// request.
throw std::runtime_error("audio-cpp: cannot write " + path + ": " +
err.what());
}
}
engine::runtime::AudioBuffer buffer_from_mono(std::vector<float> samples,
int sample_rate) {
engine::runtime::AudioBuffer buffer;
buffer.sample_rate = sample_rate;
buffer.channels = 1;
buffer.samples = std::move(samples);
return buffer;
}
} // namespace audiocpp_backend