mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-10 16:27:58 -04:00
* feat(crispasr): backend source files (Go gRPC server, C-ABI shim, build files) Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * polish(crispasr): brand error strings + fix stale shim comment Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * build(crispasr): register backend in root Makefile Mirror the whisper Go backend registration for the new crispasr backend: NOTPARALLEL entry, prepare-test-extra/test-extra hooks, BACKEND_CRISPASR definition, docker-build target generation, and the docker-build-backends aggregate target. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * ci(crispasr): add backend build matrix entries Mirror the 11 whisper golang Dockerfile matrix entries (CPU amd64/arm64, CUDA 12/13, L4T CUDA 13, Intel SYCL f32/f16, Vulkan amd64/arm64, L4T arm64, ROCm hipblas) with backend and tag-suffix substituted to crispasr. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): add crispasr backend gallery entries Add the crispasr meta anchor and its full set of image gallery entries (cpu, metal, cuda12/13, rocm, intel-sycl f32/f16, vulkan, L4T arm64, L4T cuda13 arm64, plus -development variants), mirroring the whisper backend gallery block. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * ci(crispasr): bump CRISPASR_VERSION via bump_deps workflow Track CrispStrobe/CrispASR main branch and bump CRISPASR_VERSION in backend/go/crispasr/Makefile. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * build(crispasr): don't wire fixture-gated test into test-extra Mirror the whisper Go backend: its AudioTranscription test is gated on model/audio fixtures and skips in CI, so building crispasr (the heaviest ggml compile in the tree) inside the unit-test lane adds a long compile for zero coverage. The backend image build in backend-matrix.yml remains the authoritative compile check. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * ci(crispasr): add darwin metal build entry (mirror whisper) The metal-crispasr gallery entries and capabilities.metal mapping reference -metal-darwin-arm64-crispasr, which is only produced by an includeDarwin entry. Mirror whisper's darwin metal entry so the tag actually gets built. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * ci(crispasr): place hipblas matrix entry next to whisper twin Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(crispasr): register crispasr as pref-only ASR backend + test Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(crispasr): port whisper behavioral suite (cancellation + streaming) Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(crispasr): fix skip message env var names to CRISPASR_* Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(crispasr): switch shim to crispasr_session_* multi-architecture API The shim used whisper_full(), which in CrispASR is the whisper-only path: libcrispasr only transcribes Whisper GGUFs through it. Multi-architecture transcription (Parakeet, Voxtral, Qwen3-ASR, Canary, Granite, FunASR, Paraformer, SenseVoice, ...) goes through the crispasr_session_* C-ABI, which auto-detects the architecture from the GGUF and dispatches to the matching backend. Rewrite the C shim around crispasr_session_open / _transcribe_lang / _result_* and add get_backend() so the selected backend is logged. load_model now takes a threads param (session_open binds n_threads at open). The session result is segment+word based with no token IDs and no per-decode callback, so drop n_tokens / get_token_id / get_segment_speaker_turn_next / set_new_segment_callback. set_abort is kept for API parity but is best-effort: the session transcribe is blocking with no abort hook. Update the purego bindings and gocrispasr.go to match: tokens are left empty, speaker-turn handling is removed, and AudioTranscriptionStream emits one delta per non-empty segment after the blocking decode returns (no progressive streaming via the session API), preserving the concat(deltas) == final.Text invariant. crispasr_session_set_translate is exported by libcrispasr but not declared in crispasr.h, so it is forward-declared in the shim alongside the open/transcribe/result functions. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * build(crispasr): link full CrispASR backend set for multi-arch support The shim's crispasr_session_* dispatch calls into the per-architecture backend libs (parakeet, voxtral, qwen3_asr, canary, funasr, paraformer, sensevoice, ...), which CrispASR builds as static archives. Linking only crispasr + ggml dead-stripped every backend object from the final module (nm backend-symbol count: 0), leaving a whisper-only .so. Link the same backend set as crispasr-cli so the static archives are pulled in. After this the module carries the backend symbols (nm count 407, .so grows from ~2.1MB to ~6.7MB) and the session API can dispatch to every compiled-in architecture. Also rewrite ${CMAKE_SOURCE_DIR}/examples/talk-llama to ${PROJECT_SOURCE_DIR}/... in the vendored src/CMakeLists.txt: CrispASR locates its vendored llama.cpp via ${CMAKE_SOURCE_DIR}, which is wrong when CrispASR is add_subdirectory'd (CMAKE_SOURCE_DIR points at this backend dir, not the CrispASR root). PROJECT_SOURCE_DIR is correct both standalone and as a subproject; the sed is idempotent. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(crispasr): adapt suite to session API (blocking, no decode callback) Register the new symbol set (drop the removed token/speaker/callback funcs, add get_backend; load_model now takes 2 args). The session transcribe is blocking with no abort hook, so a mid-decode cancel can't interrupt it: change the cancellation spec to cancel the context before the call and assert codes.Canceled from the pre-call ctx.Err() check, dropping the <5s mid-decode timing assertion. The streaming spec still holds with per-segment post-decode emission (>=2 deltas, concat(deltas) == final.Text). Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): add CrispASR ASR model entries (-crispasr) Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(gallery): keep only session-auto-detectable CrispASR ASR models The crispasr backend loads models via crispasr_session_open, which auto-detects the backend from the GGUF general.architecture using crispasr_detect_backend_from_gguf. Architectures not in that detect map cannot be opened, so those gallery entries fail to load. Removed entries whose architecture is not wired into CrispASR v0.6.11's session auto-detect router (they can be re-added when upstream maps them): - Not in the detect map: data2vec, firered-asr, funasr, fun-asr-mlt-nano, glm-asr, hubert, kyutai-stt, mega-asr, mimo-asr, moonshine{,-de,-streaming,-tiny-de}, omniasr{,-llm,-llm-1b}, paraformer, sensevoice. - Pending verification (filename-heuristic routed, not arch-detected): parakeet-ctc-0.6b, parakeet-ctc-1.1b. Their GGUFs are routed to the fastconformer-ctc backend by a filename heuristic in the model registry, which implies general.architecture is not a mapped string. Kept the parakeet rnnt/tdt_ctc variants: convert-parakeet-to-gguf.py writes general.architecture="parakeet" unconditionally and encodes the rnnt/ctc distinction in metadata fields, so they session-auto-detect. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(crispasr): TTS synthesis via crispasr_session_synthesize (24kHz) Add tts_synthesize/tts_free/tts_set_voice to the C-ABI shim. They reuse the already-open g_session (crispasr_session_open auto-detects a TTS model) and dispatch to the upstream synthesis call, which returns malloc'd 24 kHz mono float PCM. Orpheus needs a SNAC codec path that we do not set, so it returns NULL here and surfaces as an error Go-side. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(crispasr): implement TTS/TTSStream gRPC methods Bind the new shim functions via purego and implement TTS, TTSStream and a writeWAV24k helper. synthesize copies the C-owned PCM out before freeing it; TTS writes a 24 kHz mono 16-bit WAV to req.Dst via go-audio/wav. CrispASR has no progressive synth, so TTSStream synthesizes fully, encodes to WAV, and emits the bytes as a single chunk; it owns the results-channel close (the gRPC server wrapper ranges until close), mirroring vibevoice-cpp's TTSStream. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(crispasr): log when a TTS voice override is not honored Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): add CrispASR vibevoice-tts model entry Only vibevoice-tts works through the current shim: qwen3-tts, chatterbox, and orpheus require companion codec/s3gen/SNAC paths (set_codec_path / set_s3gen_path) that the shim doesn't wire yet, and kokoro/indextts/voxcpm2 aren't in the session auto-detect map. Those are follow-ups. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(crispasr): gated TTS synthesis spec Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(crispasr): satisfy golangci-lint (errcheck defers + unsafeptr nolint) The crispasr Go file is entirely new, so new-from-merge-base lints every line (unlike the grandfathered whisper backend it was forked from): - handle os.RemoveAll / fh.Close return values in AudioTranscription - annotate the two intentional C-pointer unsafe.Slice sites with //nolint:govet Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(crispasr): backend: and codec: model options (explicit arch + companion files) Add two model-config options to the CrispASR backend via opts.Options: - backend:<name> selects an explicit CrispASR backend (bypassing auto-detect) by routing load_model through crispasr_session_open_explicit, unlocking architectures the detector won't pick on its own (qwen3, cohere, granite, voxtral, moonshine, mimo-asr, orpheus, kokoro, chatterbox, etc.). - codec:<path> loads a companion file (qwen3-tts codec, orpheus SNAC, chatterbox s3gen, or mimo-asr tokenizer) via the universal crispasr_session_set_codec_path setter after the session opens. A relative path resolves against the model directory. rc==0 means success or not-applicable; only a negative rc is fatal. The C shim load_model gains a backend_name argument and a new set_codec_path entry point; the Go bridge parses the prefix:value options and registers the new symbol. The vad_only path is unchanged. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): expand CrispASR models via backend:/codec: options (explicit arch + companions) Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(gallery): use virtual.yaml base for crispasr models The crispasr entries are just backend + model + a couple options, fully expressed inline via overrides:/files: in gallery/index.yaml. Point each url: at the shared gallery/virtual.yaml (the established 'virtual' model trick) and drop the 36 redundant per-model gallery/*-crispasr.yaml files. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(gallery): drop voice-requiring TTS entries (keep vibevoice-tts) Real e2e showed qwen3-tts/orpheus/chatterbox don't synthesize through the current shim: the codec: companion loads fine, but these engines additionally need a voice pack / voice prompt / reference clip (qwen3-tts base errors 'no voice'; chatterbox is zero-shot cloning; orpheus uses named voices) that the backend doesn't wire. (qwen3-tts also can't auto-detect: its GGUF arch is 'qwen3tts', unmapped by the detector — would need backend:qwen3-tts.) Removed to avoid shipping non-working gallery entries; vibevoice-tts (built-in voice, e2e-verified) remains the working TTS. Voice-pack wiring is a follow-up. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(crispasr): speaker: and voice: TTS options (baked speakers + voice packs/prompts) speaker:<name> -> crispasr_session_set_speaker_name (baked speakers: qwen3-tts CustomVoice, orpheus). voice:<path>(+voice_text:<ref>) -> crispasr_session_set_voice (voice-pack GGUF, or WAV zero-shot clone with ref text). Applied at Load as the default voice; req.Voice still overrides the speaker per request. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): re-add e2e-verified TTS engines (chatterbox, qwen3-tts-customvoice, orpheus) Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
194 lines
6.5 KiB
Go
194 lines
6.5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/ebitengine/purego"
|
|
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
func TestCrispASR(t *testing.T) {
|
|
RegisterFailHandler(Fail)
|
|
RunSpecs(t, "CrispASR Backend Suite")
|
|
}
|
|
|
|
var (
|
|
libLoadOnce sync.Once
|
|
libLoadErr error
|
|
)
|
|
|
|
// ensureLibLoaded mirrors main.go's bootstrap so a Go test can drive the
|
|
// bridge without spinning up the gRPC server. Skips the current spec when the
|
|
// shared library isn't present (e.g. running before `make backends/whisper`).
|
|
func ensureLibLoaded() {
|
|
libLoadOnce.Do(func() {
|
|
libName := os.Getenv("CRISPASR_LIBRARY")
|
|
if libName == "" {
|
|
libName = "./libgocrispasr-fallback.so"
|
|
}
|
|
if _, err := os.Stat(libName); err != nil {
|
|
libLoadErr = err
|
|
return
|
|
}
|
|
gosd, err := purego.Dlopen(libName, purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
|
if err != nil {
|
|
libLoadErr = err
|
|
return
|
|
}
|
|
purego.RegisterLibFunc(&CppLoadModel, gosd, "load_model")
|
|
purego.RegisterLibFunc(&CppSetCodecPath, gosd, "set_codec_path")
|
|
purego.RegisterLibFunc(&CppTranscribe, gosd, "transcribe")
|
|
purego.RegisterLibFunc(&CppGetSegmentText, gosd, "get_segment_text")
|
|
purego.RegisterLibFunc(&CppGetSegmentStart, gosd, "get_segment_t0")
|
|
purego.RegisterLibFunc(&CppGetSegmentEnd, gosd, "get_segment_t1")
|
|
purego.RegisterLibFunc(&CppGetBackend, gosd, "get_backend")
|
|
purego.RegisterLibFunc(&CppSetAbort, gosd, "set_abort")
|
|
purego.RegisterLibFunc(&CppTTSSynthesize, gosd, "tts_synthesize")
|
|
purego.RegisterLibFunc(&CppTTSFree, gosd, "tts_free")
|
|
purego.RegisterLibFunc(&CppTTSSetVoice, gosd, "tts_set_voice")
|
|
purego.RegisterLibFunc(&CppTTSSetVoiceFile, gosd, "tts_set_voice_file")
|
|
})
|
|
if libLoadErr != nil {
|
|
Skip("whisper library not loadable: " + libLoadErr.Error())
|
|
}
|
|
}
|
|
|
|
// fixturesOrSkip returns the model + audio paths or skips the spec if either
|
|
// env var is unset. The test never runs in default CI — it requires a real
|
|
// whisper model and a long audio file (~3 minutes) on disk.
|
|
func fixturesOrSkip() (string, string) {
|
|
modelPath := os.Getenv("CRISPASR_MODEL_PATH")
|
|
audioPath := os.Getenv("CRISPASR_AUDIO_PATH")
|
|
if modelPath == "" || audioPath == "" {
|
|
Skip("set CRISPASR_MODEL_PATH and CRISPASR_AUDIO_PATH to run this spec")
|
|
}
|
|
return modelPath, audioPath
|
|
}
|
|
|
|
// ttsModelOrSkip returns the TTS model path or skips the spec when the env var
|
|
// is unset. Like the transcription fixtures, this never runs in default CI — it
|
|
// needs a real TTS model (e.g. a vibevoice GGUF) on disk.
|
|
func ttsModelOrSkip() string {
|
|
modelPath := os.Getenv("CRISPASR_TTS_MODEL_PATH")
|
|
if modelPath == "" {
|
|
Skip("set CRISPASR_TTS_MODEL_PATH to run this spec")
|
|
}
|
|
return modelPath
|
|
}
|
|
|
|
var _ = Describe("CrispASR", func() {
|
|
Context("AudioTranscription cancellation", func() {
|
|
It("returns codes.Canceled on a pre-cancelled context and still succeeds afterwards", func() {
|
|
modelPath, audioPath := fixturesOrSkip()
|
|
ensureLibLoaded()
|
|
|
|
w := &CrispASR{}
|
|
Expect(w.Load(&pb.ModelOptions{ModelFile: modelPath})).To(Succeed())
|
|
|
|
// The session transcribe is blocking and exposes no abort hook, so
|
|
// a mid-decode cancel can't interrupt it. The contract we can rely
|
|
// on is the pre-call ctx.Err() check: a context cancelled before
|
|
// the call must yield codes.Canceled without starting a decode.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
_, err := w.AudioTranscription(ctx, &pb.TranscriptRequest{
|
|
Dst: audioPath,
|
|
Threads: 4,
|
|
Language: "en",
|
|
})
|
|
Expect(err).To(HaveOccurred(), "expected pre-cancelled context to fail")
|
|
st, ok := status.FromError(err)
|
|
Expect(ok).To(BeTrue(), "expected gRPC status error, got %v", err)
|
|
Expect(st.Code()).To(Equal(codes.Canceled), "expected codes.Canceled, got %v", err)
|
|
|
|
// Subsequent transcription must succeed — proves g_abort reset.
|
|
res, err := w.AudioTranscription(context.Background(), &pb.TranscriptRequest{
|
|
Dst: audioPath,
|
|
Threads: 4,
|
|
Language: "en",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred(), "post-cancel transcription failed")
|
|
Expect(res.Text).ToNot(BeEmpty(), "post-cancel transcription returned empty text")
|
|
})
|
|
})
|
|
|
|
Context("AudioTranscriptionStream", func() {
|
|
It("emits multiple deltas progressively for a multi-segment clip", func() {
|
|
modelPath, audioPath := fixturesOrSkip()
|
|
ensureLibLoaded()
|
|
|
|
w := &CrispASR{}
|
|
Expect(w.Load(&pb.ModelOptions{ModelFile: modelPath})).To(Succeed())
|
|
|
|
results := make(chan *pb.TranscriptStreamResponse, 64)
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- w.AudioTranscriptionStream(context.Background(), &pb.TranscriptRequest{
|
|
Dst: audioPath,
|
|
Threads: 4,
|
|
Language: "en",
|
|
Stream: true,
|
|
}, results)
|
|
}()
|
|
|
|
var deltas []string
|
|
var assembled strings.Builder
|
|
var finalText string
|
|
var finalSegmentCount int
|
|
for chunk := range results {
|
|
if d := chunk.GetDelta(); d != "" {
|
|
deltas = append(deltas, d)
|
|
assembled.WriteString(d)
|
|
}
|
|
if final := chunk.GetFinalResult(); final != nil {
|
|
finalText = final.GetText()
|
|
finalSegmentCount = len(final.GetSegments())
|
|
}
|
|
}
|
|
Expect(<-done).ToNot(HaveOccurred())
|
|
|
|
// One delta per non-empty segment is emitted after the blocking
|
|
// decode returns (the session API has no per-decode callback), so a
|
|
// multi-segment clip MUST produce >=2 delta events, and
|
|
// concat(deltas) MUST equal final.Text exactly.
|
|
Expect(len(deltas)).To(BeNumerically(">=", 2),
|
|
"expected multiple deltas from a multi-segment clip, got %d (assembled=%q)",
|
|
len(deltas), assembled.String())
|
|
Expect(finalSegmentCount).To(BeNumerically(">=", 2),
|
|
"expected final to carry multiple segments")
|
|
Expect(assembled.String()).To(Equal(finalText),
|
|
"concat(deltas) must equal final.Text")
|
|
})
|
|
})
|
|
|
|
Context("TTS", func() {
|
|
It("synthesizes a non-empty WAV", func() {
|
|
ttsModel := ttsModelOrSkip()
|
|
ensureLibLoaded()
|
|
|
|
w := &CrispASR{}
|
|
Expect(w.Load(&pb.ModelOptions{ModelFile: ttsModel})).To(Succeed())
|
|
|
|
dst := filepath.Join(GinkgoT().TempDir(), "out.wav")
|
|
Expect(w.TTS(&pb.TTSRequest{Text: "Hello from CrispASR.", Dst: dst})).To(Succeed())
|
|
|
|
info, err := os.Stat(dst)
|
|
Expect(err).ToNot(HaveOccurred(), "synthesized WAV should exist at %q", dst)
|
|
// A real 24 kHz mono WAV is a 44-byte header plus samples; anything
|
|
// this small would mean an empty/failed synth.
|
|
Expect(info.Size()).To(BeNumerically(">", 1024),
|
|
"expected a non-trivial WAV, got %d bytes", info.Size())
|
|
})
|
|
})
|
|
})
|