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>
128 lines
3.9 KiB
Go
128 lines
3.9 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"math"
|
|
"os"
|
|
|
|
"github.com/ebitengine/purego"
|
|
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
func ttsReq(text, voice, lang, dst string) *pb.TTSRequest {
|
|
r := &pb.TTSRequest{Text: text, Voice: voice, Dst: dst}
|
|
if lang != "" {
|
|
r.Language = &lang
|
|
}
|
|
return r
|
|
}
|
|
|
|
// wavRMS parses a 16-bit PCM WAV file and returns (sampleRate, channels, RMS
|
|
// of the normalized samples).
|
|
func wavRMS(path string) (int, int, float64) {
|
|
data, err := os.ReadFile(path) // #nosec G304 -- test-owned temp file
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(len(data)).To(BeNumerically(">", 44))
|
|
Expect(string(data[0:4])).To(Equal("RIFF"))
|
|
Expect(string(data[8:12])).To(Equal("WAVE"))
|
|
channels := int(binary.LittleEndian.Uint16(data[22:24]))
|
|
rate := int(binary.LittleEndian.Uint32(data[24:28]))
|
|
// Find the data chunk (go-audio writes fmt first, data after).
|
|
off := 12
|
|
for off+8 <= len(data) {
|
|
id := string(data[off : off+4])
|
|
sz := int(binary.LittleEndian.Uint32(data[off+4 : off+8]))
|
|
if id == "data" {
|
|
pcm := data[off+8:]
|
|
if sz < len(pcm) {
|
|
pcm = pcm[:sz]
|
|
}
|
|
var sum float64
|
|
n := len(pcm) / 2
|
|
for i := 0; i < n; i++ {
|
|
s := float64(int16(binary.LittleEndian.Uint16(pcm[i*2:]))) / 32768.0
|
|
sum += s * s
|
|
}
|
|
Expect(n).To(BeNumerically(">", 0))
|
|
return rate, channels, math.Sqrt(sum / float64(n))
|
|
}
|
|
off += 8 + sz
|
|
}
|
|
Fail("no data chunk found in " + path)
|
|
return 0, 0, 0
|
|
}
|
|
|
|
var _ = Describe("magpie-tts-cpp e2e", Label("e2e"), func() {
|
|
var (
|
|
loaded bool
|
|
backend *MagpieTtsCpp
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
modelPath := os.Getenv("MAGPIETTS_MODEL")
|
|
if modelPath == "" {
|
|
Skip("MAGPIETTS_MODEL not set; skipping e2e")
|
|
}
|
|
if !loaded {
|
|
lib := os.Getenv("MAGPIETTS_LIBRARY")
|
|
if lib == "" {
|
|
lib = "./libgomagpiettscpp-fallback.so"
|
|
}
|
|
h, err := purego.Dlopen(lib, purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
purego.RegisterLibFunc(&CppAbiVersion, h, "magpie_tts_capi_abi_version")
|
|
purego.RegisterLibFunc(&CppLoad, h, "magpie_tts_capi_load")
|
|
purego.RegisterLibFunc(&CppFree, h, "magpie_tts_capi_free")
|
|
purego.RegisterLibFunc(&CppSynthesize, h, "magpie_tts_capi_synthesize")
|
|
purego.RegisterLibFunc(&CppFreeAudio, h, "magpie_tts_capi_free_audio")
|
|
purego.RegisterLibFunc(&CppLastError, h, "magpie_tts_capi_last_error")
|
|
|
|
backend = &MagpieTtsCpp{}
|
|
Expect(backend.Load(&pb.ModelOptions{ModelFile: modelPath})).To(Succeed())
|
|
loaded = true
|
|
}
|
|
})
|
|
|
|
It("synthesizes a non-silent 22.05 kHz mono WAV via TTS", func() {
|
|
dst := GinkgoT().TempDir() + "/out.wav"
|
|
Expect(backend.TTS(ttsReq("Hello world, this is a test.", "Aria", "en", dst))).To(Succeed())
|
|
rate, channels, rms := wavRMS(dst)
|
|
Expect(rate).To(Equal(22050))
|
|
Expect(channels).To(Equal(1))
|
|
Expect(rms).To(BeNumerically(">", 0.01), "audio should not be silent")
|
|
})
|
|
|
|
It("accepts a case-insensitive voice and a speaker index", func() {
|
|
dst := GinkgoT().TempDir() + "/out.wav"
|
|
Expect(backend.TTS(ttsReq("Short test.", "sofia", "en", dst))).To(Succeed())
|
|
Expect(backend.TTS(ttsReq("Short test.", "1", "en", dst))).To(Succeed())
|
|
})
|
|
|
|
It("rejects an unknown voice before reaching the engine", func() {
|
|
dst := GinkgoT().TempDir() + "/out.wav"
|
|
err := backend.TTS(ttsReq("Short test.", "not-a-speaker", "en", dst))
|
|
Expect(err).To(MatchError(ContainSubstring("unknown voice")))
|
|
})
|
|
|
|
It("streams a self-describing WAV via TTSStream", func() {
|
|
results := make(chan []byte, 4096)
|
|
done := make(chan error, 1)
|
|
go func() { done <- backend.TTSStream(ttsReq("Hello there, streaming test.", "", "", ""), results) }()
|
|
|
|
var chunks int
|
|
var first []byte
|
|
for c := range results {
|
|
if chunks == 0 {
|
|
first = c
|
|
}
|
|
chunks++
|
|
}
|
|
Expect(<-done).ToNot(HaveOccurred())
|
|
Expect(chunks).To(BeNumerically(">=", 2))
|
|
Expect(string(first[0:4])).To(Equal("RIFF"))
|
|
Expect(string(first[8:12])).To(Equal("WAVE"))
|
|
})
|
|
})
|