Files
LocalAI/backend/go/parakeet-cpp/roles.go
T
mudler-agentandEttore Di Giacinto 6c718fdda7 feat(parakeet-cpp): VAD (Silero and the Moondream head), vad_model option and gallery entries (#12463)
* feat(parakeet-cpp): implement the VAD call and add the vad_model option

The backend now serves the VAD gRPC call (POST /vad and /v1/vad) with the
standalone VAD of libparakeet. It accepts a Silero VAD GGUF as the model
file, or an ASR model with a VAD head (Moondream Ultra and Redux). The
request audio is float32 PCM at 16 kHz; the response lists the speech
segments in seconds, like the silero-vad backend. A model with neither
fails the request with the library message.

The vad_threshold, vad_min_pause, vad_min_speech, vad_speech_pad and
vad_max_segment options tune the segmenter. Unset values keep the
defaults of the detector in use, and a bad value fails the load.

The vad_model option names a Silero GGUF, resolved against the models
directory like the other companion files. It lets any ASR model cut long
audio at pauses through parakeet_capi_transcribe_path_json_vad_with, and
it implies vad. vad:true alone still uses the model's own head.

The new symbols are probed like the existing optional ones. A library
without them still loads; the feature that needs one fails with a clear
message only when it is used.

Assisted-by: Claude:claude-sonnet-5-5 [go test]

* feat(gallery): add parakeet-cpp VAD entries and a v3 plus Silero example

Add VAD-only entries for the parakeet-cpp backend: the VAD heads of
Moondream Redux (packed, CPU) and Ultra (Q8_0), which share their files
with the existing ASR entries, and Silero VAD v6.2.3 as a GGUF (MIT,
Silero Team). The parakeet-cpp-vad entry installs Silero; it has no variants,
because variant ranking prefers the larger build that fits and these are
different detectors.

Add parakeet-cpp-tdt-0.6b-v3-silero-vad, a v3 entry that sets vad_model
so long audio is cut at pauses by Silero.

The Silero GGUF entries point at the intended Hugging Face URL of the
file; the existing silero-vad entries are unchanged. A test checks the
usecases, the shared files and the default entry and the vad_model reference.

Assisted-by: Claude:claude-sonnet-5-5 [go test]

* docs: describe parakeet-cpp VAD and the vad_model option

Document the VAD endpoint on the parakeet-cpp backend (Silero GGUF and
the VAD heads of Moondream Ultra and Redux), the vad_* tuning options,
and the vad_model option that lets an ASR model without a VAD head cut
long audio with Silero.

Assisted-by: Claude:claude-sonnet-5-5

* chore(parakeet-cpp): bump parakeet.cpp to 6165e3d

Pin the release that adds the standalone VAD (Ultra/Redux head and
Silero) and the C API calls the backend now uses.

Assisted-by: Claude:claude-sonnet-5-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-04 01:02:09 +02:00

299 lines
11 KiB
Go

package main
import (
"errors"
"fmt"
"path/filepath"
"strings"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/xlog"
)
// Model kinds returned by parakeet_capi_model_kind (ABI v8; mirrors the
// PARAKEET_MODEL_KIND_* defines in parakeet_capi.h).
const (
modelKindNone = 0
modelKindASR = 1
modelKindDiarization = 2
modelKindSound = 3
modelKindSpeaker = 4
modelKindVAD = 5 // Silero VAD GGUF
)
// Diarization streaming latency modes (mirrors PARAKEET_DIAR_LATENCY_* in
// parakeet_capi.h). diarLatencyLow is the spec's default when
// diarization_latency: is unset.
const (
diarLatencyModel int32 = 0
diarLatencyLow int32 = 1
diarLatencyVeryLow int32 = 2
diarLatencyUltraLow int32 = 3
)
// modelKindName renders a model kind for error messages.
func modelKindName(kind int32) string {
switch kind {
case modelKindASR:
return "ASR"
case modelKindDiarization:
return "diarization"
case modelKindSound:
return "sound"
case modelKindSpeaker:
return "speaker"
case modelKindVAD:
return "VAD"
default:
return "unknown"
}
}
// optString reads a string model option (key:value form) from ModelOptions,
// returning "" when the key is absent. Same strings.Cut parsing as optInt.
func optString(opts *pb.ModelOptions, key string) string {
for _, o := range opts.GetOptions() {
k, v, ok := strings.Cut(o, ":")
if ok && strings.TrimSpace(k) == key {
return strings.TrimSpace(v)
}
}
return ""
}
// resolveModelPath resolves a companion model option's path against
// modelPath (opts.ModelPath, the LocalAI models root): an absolute p, or an
// empty modelPath, passes through unchanged; anything else is joined onto
// modelPath. Mirrors vibevoice-cpp's resolvePath for tokenizer=/voice=/etc.
func resolveModelPath(modelPath, p string) string {
if p == "" || filepath.IsAbs(p) || modelPath == "" {
return p
}
return filepath.Join(modelPath, p)
}
// parseDiarLatency maps the diarization_latency option value to a
// PARAKEET_DIAR_LATENCY_* mode. "" defaults to "low" (the spec's default);
// any other unrecognized value is a Load error.
func parseDiarLatency(s string) (int32, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "":
return diarLatencyLow, nil
case "model":
return diarLatencyModel, nil
case "low":
return diarLatencyLow, nil
case "very_low":
return diarLatencyVeryLow, nil
case "ultra_low":
return diarLatencyUltraLow, nil
default:
return 0, fmt.Errorf("parakeet-cpp: unknown diarization_latency %q (want model|low|very_low|ultra_low)", s)
}
}
// companionSpec is one asr_model:/diarization_model:/sound_model: option: its
// name (for error messages and path resolution), the raw option value, the
// model kind the loaded companion must report, the ParakeetCpp field it is
// assigned to on success, and a getter for that same field's current value
// (used to reject a companion whose role the primary already occupies).
type companionSpec struct {
optName string
value string
wantKind int32
assign func(*ParakeetCpp, uintptr)
current func(*ParakeetCpp) uintptr
}
// indefiniteArticle returns "an" for a word starting with a vowel sound and
// "a" otherwise, for grammatical error messages built from modelKindName.
func indefiniteArticle(word string) string {
if len(word) == 0 {
return "a"
}
switch word[0] {
case 'A', 'E', 'I', 'O', 'U', 'a', 'e', 'i', 'o', 'u':
return "an"
default:
return "a"
}
}
// loadRoles loads opts.ModelFile as the primary parakeet_ctx, classifies it
// with parakeet_capi_model_kind (ABI v8) into ctxPtr/diarCtx/tagCtx, and
// loads any companion models named in Options[] (asr_model:,
// diarization_model:, sound_model:; paths resolved against opts.ModelPath).
// It also parses diarization_latency: into p.diarLatency.
//
// Against an older libparakeet.so (CppModelKind == nil) the primary is
// treated as ASR — the pre-v8 behavior — and companion model options are
// rejected outright, since there is no way to verify what they loaded.
//
// On any failure every context this call opened (primary and any companions
// loaded before the failure) is freed before the error is returned.
func (p *ParakeetCpp) loadRoles(opts *pb.ModelOptions) error {
diarModelOpt := optString(opts, "diarization_model")
asrModelOpt := optString(opts, "asr_model")
soundModelOpt := optString(opts, "sound_model")
speakerModelOpt := optString(opts, "speaker_model")
vadModelOpt := optString(opts, "vad_model")
hasCompanionOpts := diarModelOpt != "" || asrModelOpt != "" || soundModelOpt != "" || speakerModelOpt != "" || vadModelOpt != ""
if hasCompanionOpts && CppModelKind == nil {
return errors.New("parakeet-cpp: asr_model/diarization_model/sound_model/speaker_model/vad_model options need " +
"parakeet_capi_model_kind (ABI v8) to verify what they load; the loaded libparakeet.so " +
"is too old to report companion model roles")
}
if speakerModelOpt != "" {
if CppSpeakerRegistryAddEmbedding == nil || CppSpeakerDim == nil || CppSceneStreamBeginSpeaker == nil {
return errors.New("parakeet-cpp: speaker_model needs libparakeet.so ABI 10 " +
"(parakeet_capi_speaker_registry_add_embedding); the loaded library is older")
}
}
accept, err := parseSpeakerThreshold(optString(opts, "speaker_threshold"))
if err != nil {
return err
}
margin, err := parseSpeakerMargin(optString(opts, "speaker_margin"))
if err != nil {
return err
}
latency, err := parseDiarLatency(optString(opts, "diarization_latency"))
if err != nil {
return err
}
primary := CppLoad(opts.ModelFile)
if primary == 0 {
// No ctx to ask for last_error (the C-API's last-error buffer lives on
// the ctx that was never returned). Surface the path so the operator
// at least knows which load failed.
//
// A packed ternary Redux GGUF (redux-packed) is the likeliest cause on a
// GPU build: the library refuses it with a message that goes to its own
// log, not through the C-API, so name the cause here.
return fmt.Errorf("parakeet-cpp: parakeet_capi_load failed for %q (see the backend log for the library message; "+
"a packed ternary Redux model is CPU only and is refused on a GPU backend, use the redux-f16 or redux-q8_0 file there)", opts.ModelFile)
}
loaded := []uintptr{primary}
// freeLoaded undoes everything loadRoles opened this call: every context
// it freed AND every ParakeetCpp field it may have assigned (the primary
// lands in one of ctxPtr/diarCtx/tagCtx before the companion loop runs,
// and an earlier companion's spec.assign runs before a later one fails).
// Leaving a role field pointing at a freed ctx would double-free it on a
// later Free() call.
freeLoaded := func() {
for _, c := range loaded {
CppFree(c)
}
p.ctxPtr, p.diarCtx, p.tagCtx, p.spkCtx, p.vadCtx = 0, 0, 0, 0, 0
p.companions = nil
}
primaryKind := int32(modelKindASR) // old-library default: today's behavior
if CppModelKind != nil {
primaryKind = CppModelKind(primary)
if primaryKind == modelKindNone {
xlog.Warn("parakeet-cpp: parakeet_capi_model_kind reported PARAKEET_MODEL_KIND_NONE " +
"for a successfully loaded primary; treating it as an ASR model")
}
}
switch primaryKind {
case modelKindDiarization:
p.diarCtx = primary
case modelKindSound:
p.tagCtx = primary
case modelKindSpeaker:
freeLoaded()
return errors.New("parakeet-cpp: a speaker model cannot be the primary model; " +
"use it as speaker_model: next to a diarization model")
case modelKindVAD:
p.vadCtx = primary
default:
p.ctxPtr = primary
}
specs := []companionSpec{
{"diarization_model", diarModelOpt, modelKindDiarization,
func(pp *ParakeetCpp, c uintptr) { pp.diarCtx = c },
func(pp *ParakeetCpp) uintptr { return pp.diarCtx }},
{"asr_model", asrModelOpt, modelKindASR,
func(pp *ParakeetCpp, c uintptr) { pp.ctxPtr = c },
func(pp *ParakeetCpp) uintptr { return pp.ctxPtr }},
{"sound_model", soundModelOpt, modelKindSound,
func(pp *ParakeetCpp, c uintptr) { pp.tagCtx = c },
func(pp *ParakeetCpp) uintptr { return pp.tagCtx }},
{"speaker_model", speakerModelOpt, modelKindSpeaker,
func(pp *ParakeetCpp, c uintptr) { pp.spkCtx = c },
func(pp *ParakeetCpp) uintptr { return pp.spkCtx }},
{"vad_model", vadModelOpt, modelKindVAD,
func(pp *ParakeetCpp, c uintptr) { pp.vadCtx = c },
func(pp *ParakeetCpp) uintptr { return pp.vadCtx }},
}
for _, spec := range specs {
if spec.value == "" {
continue
}
// A companion whose role the primary already occupies (e.g. asr_model:
// on an already-ASR primary) would overwrite that role field below,
// leaking the primary ctx: Free() walks ctxPtr/diarCtx/tagCtx/spkCtx, so
// the overwritten pointer is never freed. Reject it before loading.
if spec.current(p) != 0 {
freeLoaded()
return fmt.Errorf("parakeet-cpp: %s is not allowed on %s %s model",
spec.optName, indefiniteArticle(modelKindName(spec.wantKind)), modelKindName(spec.wantKind))
}
resolved := resolveModelPath(opts.ModelPath, spec.value)
cctx := CppLoad(resolved)
if cctx == 0 {
freeLoaded()
return fmt.Errorf("parakeet-cpp: failed to load %s %q", spec.optName, resolved)
}
loaded = append(loaded, cctx)
if gotKind := CppModelKind(cctx); gotKind != spec.wantKind {
freeLoaded()
return fmt.Errorf("parakeet-cpp: %s %q is %s %s model, expected %s %s model",
spec.optName, resolved, indefiniteArticle(modelKindName(gotKind)), modelKindName(gotKind),
indefiniteArticle(modelKindName(spec.wantKind)), modelKindName(spec.wantKind))
}
spec.assign(p, cctx)
p.companions = append(p.companions, cctx)
}
if vadModelOpt != "" && p.ctxPtr == 0 {
freeLoaded()
return errors.New("parakeet-cpp: vad_model cuts audio for transcription and needs an ASR model (the primary or asr_model:)")
}
if p.spkCtx != 0 && p.diarCtx == 0 {
freeLoaded()
return errors.New("parakeet-cpp: speaker_model needs a diarization model (the primary or diarization_model:)")
}
p.speakerAccept, p.speakerMargin = accept, margin
p.diarLatency = latency
return nil
}
// notASRError reports why AudioTranscription (and the streaming/live RPCs)
// cannot run when p.ctxPtr == 0: a loaded diarization or sound primary with
// no asr_model companion, named explicitly so the caller knows to use the
// right RPC instead of a generic "model not loaded". Returns nil when
// neither role is loaded (genuinely no model), leaving the caller to report
// the ordinary ModelNotLoaded error.
func (p *ParakeetCpp) notASRError() error {
switch {
case p.diarCtx != 0:
return errors.New("parakeet-cpp: loaded model is a diarization model, not ASR " +
"(use Diarize, or load with an asr_model: companion)")
case p.tagCtx != 0:
return errors.New("parakeet-cpp: loaded model is a sound model, not ASR " +
"(use SoundDetection)")
case p.vadCtx != 0:
return errors.New("parakeet-cpp: loaded model is a Silero VAD model, not ASR " +
"(use the VAD endpoint, or load an ASR model with vad_model:)")
default:
return nil
}
}