mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-31 02:18:50 -04:00
The one bidirectional stream this backend serves. The client sends a
TranscriptLiveConfig, then TranscriptLiveAudio frames; the server acknowledges
with ready, emits deltas as the audio arrives, and sends final_result once the
read side closes. There is no offline fallback: live transcription has to
consume audio incrementally, so a family with no streaming ASR is refused
rather than served a batch run, which is what this RPC's Streaming-only
mode_candidates list already says.
The driver is a new sibling of run_streaming_audio, run_streaming_live, because
the audio does not exist yet: instead of slicing a buffer it pulls frames from
the caller until the read side closes. It installs the same ScopedStreamSink in
the same order, which is not optional, since nemotron_asr returns a bare event
from process_audio_chunk and reports every partial through the sink from inside
finalize(). It buffers the wire's frames up to the family's own preferred window
rather than feeding whatever size the client's audio callback produced, and it
does not call finish_stream at all when no audio arrived, because nemotron_asr
throws "finalize requires streamed audio" and an empty transcript is the
truthful answer to transcribing nothing.
Three things the handler had to get right and one it cannot:
- The audio contract. A live request carries no samples, but nemotron_asr's
streaming prepare() throws without an audio contract, and
build_preparation_request derives it from TaskRequest::audio_input, so that
field is an EMPTY buffer holding only the rate and the channel count.
- 16 kHz or a refusal. The families express their spans in their own 16 kHz
feature domain whatever the input was, and live frames cannot be resampled
on the way in the way a file can, so an 8 kHz session would return
timestamps 2x off with a 200. core/backend hardcodes 16000 anyway.
- A mid-stream Config is refused. backend.proto calls it a decoder reset, but
deltas already on the wire cannot be retracted, so a reset would leave the
final text contradicting the transcript the client assembled. Ignoring the
message would hand a client that believes it reset the decoder a transcript
that silently continues the audio it thought it discarded.
- The stale-route identity check cannot run here: TranscriptLiveRequest
carries no ModelIdentity in either arm of its oneof, so snapshot_for does
not instantiate for it. snapshot_unchecked's comment now names that as a
second legitimate class of caller and says the fix is a proto change.
eou and eob stay false. They exist for cache-aware models that emit
end-of-utterance and end-of-backchannel tokens; audio.cpp's StreamEvent has no
equivalent signal, and a client uses eou to decide the speaker yielded the turn,
so a guess inferred from silence cuts people off mid-sentence.
The lane is held for the whole stream, which is as long as the user keeps
talking: the streaming session is stateful and cached, so a concurrent run would
interleave two callers' audio and corrupt both transcripts.
Verified against nemotron_asr over a real connection with a 14 s WAV in
512-sample frames: ready first, 59 incremental deltas with no repeated prefix,
concat(deltas) equal to final_result.text, word timestamps in nanoseconds, eou
and eob false. citrinet_asr answers UNIMPLEMENTED naming the family and listing
asr/offline. A config followed by a close returns an empty final_result rather
than hanging, and a first message that is not a config is INVALID_ARGUMENT. Two
concurrent streams both return the complete transcript.
Two cleanups on lines Task 12 touched, folded in. The DtypeAllowList terminator
is now asserted at compile time: the reported out-of-bounds read did not exist,
the single entry does terminate, but the loops have no other bound and any edit
that widened an entry would walk off the end. And the dtype guard now
short-circuits on "is there a table entry" through a new predicate rather than
on the emptiness of the description string, which would have skipped the check
on an entry with an empty allow list, i.e. on precisely the entry that refuses
every dtype.
Assisted-by: Claude:claude-opus-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
161 lines
5.6 KiB
C++
161 lines
5.6 KiB
C++
#include "family_gate.h"
|
|
|
|
#include <cctype>
|
|
#include <cstddef>
|
|
|
|
namespace audiocpp_backend {
|
|
namespace {
|
|
|
|
// PRECONDITION: `suffix` must already be lowercase. Both sides are folded, so
|
|
// this reads as symmetric, but only `value` can carry case in practice and a
|
|
// caller passing ".GGUF" would still work today for that reason alone. Do not
|
|
// rely on it: the fold on the suffix side is the only thing standing between
|
|
// this and a helper that answers false for every input, and it is not covered
|
|
// by any test, because with a lowercase suffix no input can distinguish it.
|
|
bool ends_with_ci(const std::string &value, const std::string &suffix) {
|
|
if (value.size() <= suffix.size()) {
|
|
return false; // a bare ".gguf" is an extension, not a model file
|
|
}
|
|
const size_t offset = value.size() - suffix.size();
|
|
for (size_t i = 0; i < suffix.size(); ++i) {
|
|
const auto lhs = static_cast<unsigned char>(value[offset + i]);
|
|
const auto rhs = static_cast<unsigned char>(suffix[i]);
|
|
if (std::tolower(lhs) != std::tolower(rhs)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool path_looks_like_gguf(const std::string &path) {
|
|
return ends_with_ci(path, ".gguf");
|
|
}
|
|
|
|
FamilyDecision decide_family(bool path_is_gguf, const std::string &embedded_family,
|
|
const std::string &configured_family) {
|
|
FamilyDecision decision;
|
|
|
|
if (!configured_family.empty()) {
|
|
decision.ok = true;
|
|
decision.family = configured_family;
|
|
return decision;
|
|
}
|
|
|
|
if (path_is_gguf) {
|
|
if (!embedded_family.empty()) {
|
|
decision.ok = true;
|
|
decision.family = embedded_family;
|
|
return decision;
|
|
}
|
|
decision.error =
|
|
"audio-cpp: this GGUF carries no 'audiocpp.model_spec.family' "
|
|
"metadata key, so it is not an audio.cpp model. Convert it with "
|
|
"audiocpp_gguf, or name the family explicitly with the model option "
|
|
"'family:<name>'";
|
|
return decision;
|
|
}
|
|
|
|
decision.error =
|
|
"audio-cpp: a model path that is not a standalone audio.cpp GGUF needs "
|
|
"an explicit 'family:<name>' model option, because the audio.cpp family "
|
|
"cannot be inferred from a safetensors or package directory";
|
|
return decision;
|
|
}
|
|
|
|
namespace {
|
|
|
|
// Families that ABORT THE PROCESS on a weight dtype they cannot handle, and the
|
|
// dtypes they can. See the header for why this is a list of crashes rather than
|
|
// a list of preferences.
|
|
//
|
|
// supertonic: upstream's docs/gguf.md:90 records its 16-bit GGUF column as
|
|
// "---", i.e. NOT TESTED, and its q8_0 as "No (unsupported weight dtype)". Only
|
|
// the `orig` package is marked Pass, and its 698 weight tensors are f32 while
|
|
// its 72 index and shape constants are i64. Attributed rather than assumed: the
|
|
// abort is identical through the unary TTS RPC and through TTSStream, so it is
|
|
// the packaging and not the streaming path.
|
|
//
|
|
// TO REMOVE AN ENTRY: bump AUDIO_CPP_VERSION past a fix, load a package in the
|
|
// refused dtype, and synthesise. If audio comes out, delete the entry. No test
|
|
// can do that for you, which is exactly why it is written here: the test beside
|
|
// this file pins WHAT the table says, not whether upstream has moved on. Do not
|
|
// widen an entry without running that, because what it prevents is a process
|
|
// death rather than a wrong answer.
|
|
struct DtypeAllowList {
|
|
// NULL TERMINATED, and the terminator occupies one of these slots: both
|
|
// loops below stop at the first nullptr and have no other bound, so an entry
|
|
// that named three dtypes would leave them reading past the end of the
|
|
// array. That is undefined behaviour rather than a wrong answer, and it is
|
|
// one keystroke away from any edit that widens an entry, so the terminator
|
|
// is asserted at compile time below rather than trusted.
|
|
static constexpr std::size_t kSlots = 3;
|
|
|
|
const char *family;
|
|
const char *allowed[kSlots];
|
|
};
|
|
|
|
constexpr DtypeAllowList kDtypeAllowLists[] = {
|
|
{"supertonic", {"f32", "i64", nullptr}},
|
|
};
|
|
|
|
constexpr bool allow_lists_are_terminated() {
|
|
for (const auto &entry : kDtypeAllowLists) {
|
|
if (entry.allowed[DtypeAllowList::kSlots - 1] != nullptr) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static_assert(allow_lists_are_terminated(),
|
|
"every DtypeAllowList must leave its last slot null: the lookups "
|
|
"below stop at the first nullptr and would otherwise read past "
|
|
"the end of the array");
|
|
|
|
const DtypeAllowList *find_allow_list(const std::string &family) {
|
|
for (const auto &entry : kDtypeAllowLists) {
|
|
if (family == entry.family) {
|
|
return &entry;
|
|
}
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool family_has_weight_dtype_allow_list(const std::string &family) {
|
|
return find_allow_list(family) != nullptr;
|
|
}
|
|
|
|
bool weight_dtype_is_supported(const std::string &family, const std::string &dtype) {
|
|
const DtypeAllowList *list = find_allow_list(family);
|
|
if (list == nullptr) {
|
|
return true;
|
|
}
|
|
for (const char *const *name = list->allowed; *name != nullptr; ++name) {
|
|
if (dtype == *name) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
std::string supported_weight_dtypes(const std::string &family) {
|
|
const DtypeAllowList *list = find_allow_list(family);
|
|
if (list == nullptr) {
|
|
return {};
|
|
}
|
|
std::string out;
|
|
for (const char *const *name = list->allowed; *name != nullptr; ++name) {
|
|
if (!out.empty()) {
|
|
out += ", ";
|
|
}
|
|
out += *name;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
} // namespace audiocpp_backend
|