Files
LocalAI/backend/go/magpie-tts-cpp/magpiettscpp_test.go
mudler's LocalAI [bot] 2d889e61a6 feat(backend): add magpie-tts-cpp text-to-speech backend (#11115)
* 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>
2026-07-25 08:38:48 +02:00

116 lines
3.7 KiB
Go

package main
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestMagpieTtsCpp(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "magpie-tts-cpp suite")
}
var _ = Describe("resolveSpeaker", func() {
It("canonicalizes case-insensitive names", func() {
for in, want := range map[string]string{
"aria": "Aria", "JASON": "Jason", "john": "John",
"Leo": "Leo", "sofia": "Sofia",
} {
got, err := resolveSpeaker(in, "")
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal(want))
}
})
It("accepts indices 0-4", func() {
for in, want := range map[string]string{
"0": "Aria", "1": "Jason", "2": "John", "3": "Leo", "4": "Sofia",
} {
got, err := resolveSpeaker(in, "")
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal(want))
}
})
It("selects the engine default on empty", func() {
got, err := resolveSpeaker("", "")
Expect(err).ToNot(HaveOccurred())
Expect(got).To(BeEmpty())
})
It("falls back to the model-level default speaker", func() {
got, err := resolveSpeaker("", "sofia")
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal("Sofia"))
})
It("prefers the request voice over the fallback", func() {
got, err := resolveSpeaker("leo", "sofia")
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal("Leo"))
})
It("rejects out-of-range indices", func() {
_, err := resolveSpeaker("5", "")
Expect(err).To(HaveOccurred())
_, err = resolveSpeaker("-1", "")
Expect(err).To(HaveOccurred())
})
It("rejects unknown names with the valid choices", func() {
_, err := resolveSpeaker("serena", "")
Expect(err).To(MatchError(ContainSubstring("Aria, Jason, John, Leo, Sofia")))
})
})
var _ = Describe("resolveLanguage", func() {
strp := func(s string) *string { return &s }
It("defaults to empty (engine picks en)", func() {
Expect(resolveLanguage(nil, "")).To(BeEmpty())
})
It("canonicalizes case for known codes", func() {
Expect(resolveLanguage(strp("EN"), "")).To(Equal("en"))
Expect(resolveLanguage(strp("pt-br"), "")).To(Equal("pt-BR"))
Expect(resolveLanguage(strp("AR-MSA"), "")).To(Equal("ar-MSA"))
})
It("falls back to the model-level default language", func() {
Expect(resolveLanguage(nil, "de")).To(Equal("de"))
Expect(resolveLanguage(strp(""), "PT-BR")).To(Equal("pt-BR"))
})
It("prefers the request language over the fallback", func() {
Expect(resolveLanguage(strp("it"), "de")).To(Equal("it"))
})
It("passes unknown codes through verbatim", func() {
Expect(resolveLanguage(strp("zh"), "")).To(Equal("zh"))
})
})
var _ = Describe("parseOptions", func() {
It("reads speaker and language defaults", func() {
o := parseOptions([]string{"speaker:Jason", "language:de"})
Expect(o.speaker).To(Equal("Jason"))
Expect(o.language).To(Equal("de"))
})
It("accepts the voice/lang aliases and ignores unknown keys", func() {
o := parseOptions([]string{"voice: sofia ", "lang: pt-BR", "bogus:1", "novalue"})
Expect(o.speaker).To(Equal("sofia"))
Expect(o.language).To(Equal("pt-BR"))
})
})
var _ = Describe("audio encoding", func() {
It("emits a well-formed streaming mono WAV header", func() {
h := wavHeaderMono()
Expect(h).To(HaveLen(44))
Expect(string(h[0:4])).To(Equal("RIFF"))
Expect(string(h[8:12])).To(Equal("WAVE"))
// channels (offset 22) == 1, sample rate (offset 24) == 22050
Expect(int(h[22]) | int(h[23])<<8).To(Equal(1))
Expect(int(h[24]) | int(h[25])<<8 | int(h[26])<<16 | int(h[27])<<24).To(Equal(22050))
})
It("clamps float PCM to int16", func() {
b := floatToPCM16LE([]float32{2, -2, 0})
Expect(b).To(HaveLen(6))
Expect(int16(uint16(b[0]) | uint16(b[1])<<8)).To(Equal(int16(32767)))
Expect(int16(uint16(b[2]) | uint16(b[3])<<8)).To(Equal(int16(-32767)))
Expect(int16(uint16(b[4]) | uint16(b[5])<<8)).To(Equal(int16(0)))
})
})