mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 18:09:05 -04:00
* feat(backend): add magpie-tts-cpp text-to-speech backend
Add a Go + purego backend wrapping the magpie-tts.cpp ggml port of NVIDIA's
Magpie TTS Multilingual 357M (encoder + autoregressive decoder over NanoCodec
tokens), producing 22.05 kHz mono audio in 5 baked voices (Aria, Jason, John,
Leo, Sofia; case-insensitive names or indices 0-4) across 9+ languages from a
single self-contained GGUF. Mirrors qwen3-tts-cpp / moss-tts-cpp: dlopen the
static-ggml shared library, bind the flat magpie_tts_capi_* C-API via purego
(no local C shim needed, the upstream .so exports it directly), and serve the
gRPC TTS + TTSStream methods behind base.SingleThread (the C context is not
reentrant across synthesize calls).
The backend CMakeLists translates the Makefile's -DGGML_{CUDA,METAL,VULKAN,HIP}
flags into upstream's MAGPIE_GGML_* toggles (upstream FORCE-overwrites the ggml
cache entries from those), pinned to magpie-tts.cpp v0.1.1
(e3f3dd1ebe22b64e7405f93b519f2d1930712568), which statically links ggml into
libmagpie-tts.so (ldd shows only system libs).
Wires the full registration: backend-matrix.yml (CPU amd64/arm64, CUDA 12/13,
Intel SYCL f16/f32, Vulkan amd64/arm64, ROCm, NVIDIA L4T + L4T CUDA 13, and
Darwin metal), backend/index.yaml metas and image entries, the root Makefile
build targets, the changed-backends backend-filter path mapping, the bump_deps
auto-bump matrix, a test-extra per-backend smoke job, the /backends/known
pref-only importer entry, the backend capabilities map (TTS + TTSStream, no
voice cloning), and the README / compatibility-table docs rows.
Verified locally: unit + e2e Ginkgo suites pass against the real q8_0 GGUF
(22.05 kHz mono WAV, RMS > 0.01), a live gRPC LoadModel + TTS round-trip
returns valid non-silent audio, and the pre-commit gates (make lint,
make test-coverage-check) pass, run manually with LOCALAI_TEST_HTTP_PORT
overriding the locally-occupied 9090.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* gallery: add magpie-tts-cpp model entries (q8_0 + f16)
Add the Magpie TTS Multilingual 357M GGUFs from mudler/magpie-tts.cpp-gguf to
the model gallery: q8_0 (~624 MB, near-lossless, fastest decode, recommended)
with an f16 (~784 MB) variant, both served by the magpie-tts-cpp backend.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* magpie-tts-cpp: bump pin to rewritten upstream v0.1.1 SHA
Upstream history was rewritten to purge accidentally committed build
artifacts; v0.1.1 now resolves to 6f7696cf.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
148 lines
4.6 KiB
Go
148 lines
4.6 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"unsafe"
|
|
|
|
"github.com/mudler/LocalAI/pkg/grpc/base"
|
|
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
// capiABIVersion is the magpie_tts_capi.h surface this backend binds. Bumped
|
|
// upstream on any breaking signature/semantics change; refuse to run on a
|
|
// mismatch instead of crashing inside a miscompiled call.
|
|
const capiABIVersion = 1
|
|
|
|
var (
|
|
// magpie_tts_capi_abi_version() int
|
|
CppAbiVersion func() int
|
|
// magpie_tts_capi_load(gguf_path) -> ctx (NULL on failure)
|
|
CppLoad func(path string) uintptr
|
|
// magpie_tts_capi_free(ctx)
|
|
CppFree func(ctx uintptr)
|
|
// magpie_tts_capi_synthesize(ctx, text, language, speaker, out_n) -> float*
|
|
// 22050 Hz mono f32 PCM in [-1,1]; NULL on failure (see last_error).
|
|
CppSynthesize func(ctx uintptr, text, language, speaker string, outN unsafe.Pointer) uintptr
|
|
// magpie_tts_capi_free_audio(ptr)
|
|
CppFreeAudio func(ptr uintptr)
|
|
// magpie_tts_capi_last_error(ctx) -> const char* (ctx-owned, "" if none)
|
|
CppLastError func(ctx uintptr) string
|
|
)
|
|
|
|
// MagpieTtsCpp serves the Magpie TTS Multilingual 357M GGUF through the
|
|
// magpie-tts.cpp C-API. The context is stateful (per-call error buffer, reused
|
|
// graph allocator) and NOT safe for concurrent synthesize calls, so
|
|
// base.SingleThread serializes everything behind the server-level lock.
|
|
type MagpieTtsCpp struct {
|
|
base.SingleThread
|
|
ctx uintptr
|
|
opts loadOptions
|
|
}
|
|
|
|
func (m *MagpieTtsCpp) Load(opts *pb.ModelOptions) error {
|
|
model := opts.ModelFile
|
|
if model == "" {
|
|
model = opts.ModelPath
|
|
}
|
|
if !filepath.IsAbs(model) && opts.ModelPath != "" {
|
|
model = filepath.Join(opts.ModelPath, model)
|
|
}
|
|
|
|
m.opts = parseOptions(opts.Options)
|
|
|
|
if abi := CppAbiVersion(); abi != capiABIVersion {
|
|
return fmt.Errorf("magpie-tts: C-API ABI mismatch: library reports v%d, backend built for v%d", abi, capiABIVersion)
|
|
}
|
|
|
|
xlog.Info("[magpie-tts-cpp] Load", "model", model)
|
|
|
|
ctx := CppLoad(model)
|
|
if ctx == 0 {
|
|
// Load failures have no context to query last_error on; the C side
|
|
// logs the reason to stderr.
|
|
return fmt.Errorf("magpie-tts: failed to load model %q", model)
|
|
}
|
|
m.ctx = ctx
|
|
return nil
|
|
}
|
|
|
|
// lastError surfaces the context's last error, falling back to a generic
|
|
// message when the C side left it empty.
|
|
func (m *MagpieTtsCpp) lastError() string {
|
|
if m.ctx == 0 {
|
|
return "no model loaded"
|
|
}
|
|
if e := CppLastError(m.ctx); e != "" {
|
|
return e
|
|
}
|
|
return "unknown error"
|
|
}
|
|
|
|
// synthesize runs one C-API synthesis and copies the PCM out of C memory.
|
|
func (m *MagpieTtsCpp) synthesize(req *pb.TTSRequest) ([]float32, error) {
|
|
if m.ctx == 0 {
|
|
return nil, fmt.Errorf("magpie-tts: no model loaded")
|
|
}
|
|
if req.Text == "" {
|
|
return nil, fmt.Errorf("magpie-tts: TTS requires text")
|
|
}
|
|
speaker, err := resolveSpeaker(req.Voice, m.opts.speaker)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
lang := resolveLanguage(req.Language, m.opts.language)
|
|
|
|
var n int32
|
|
ptr := CppSynthesize(m.ctx, req.Text, lang, speaker, unsafe.Pointer(&n)) // #nosec G103 -- out-param for the purego-bound C-API
|
|
if ptr == 0 {
|
|
return nil, fmt.Errorf("magpie-tts: synthesis failed: %s", m.lastError())
|
|
}
|
|
// Register the free as soon as we own a non-null buffer, so the n<=0 guard
|
|
// below cannot leak it (defensive: the C contract returns NULL on failure).
|
|
defer CppFreeAudio(ptr)
|
|
if n <= 0 {
|
|
return nil, fmt.Errorf("magpie-tts: synthesis produced no samples")
|
|
}
|
|
//nolint:govet // C-allocated PCM, copied out before free
|
|
src := unsafe.Slice((*float32)(unsafe.Pointer(ptr)), int(n)) // #nosec G103 -- C-allocated PCM, copied out before free
|
|
out := make([]float32, int(n))
|
|
copy(out, src)
|
|
return out, nil
|
|
}
|
|
|
|
func (m *MagpieTtsCpp) TTS(req *pb.TTSRequest) error {
|
|
if req.Dst == "" {
|
|
return fmt.Errorf("magpie-tts: TTS requires a destination path")
|
|
}
|
|
samples, err := m.synthesize(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeWAVMono(req.Dst, samples)
|
|
}
|
|
|
|
// TTSStream synthesizes one-shot (the magpie C-API has no streaming call) and
|
|
// then emits a self-describing mono WAV: a header chunk followed by the PCM in
|
|
// fixed-size slices, so the HTTP layer still receives a streamed WAV (the gRPC
|
|
// TTSStream path never sets Message, so the backend owns the header - see
|
|
// core/backend/tts.go:ModelTTSStream).
|
|
func (m *MagpieTtsCpp) TTSStream(req *pb.TTSRequest, results chan []byte) error {
|
|
defer close(results)
|
|
samples, err := m.synthesize(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
results <- wavHeaderMono()
|
|
const sampleChunk = 4096 // mono samples per emitted chunk
|
|
for off := 0; off < len(samples); off += sampleChunk {
|
|
end := off + sampleChunk
|
|
if end > len(samples) {
|
|
end = len(samples)
|
|
}
|
|
results <- floatToPCM16LE(samples[off:end])
|
|
}
|
|
return nil
|
|
}
|