Files
LocalAI/backend/go/moss-tts-cpp/mossttscpp_test.go
LocalAI [bot] 3bb0d1cb49 feat(backend): add moss-tts-cpp text-to-speech backend (#10860)
* feat(backend): add moss-tts-cpp text-to-speech backend

Add a Go + purego backend wrapping the moss-tts.cpp ggml port of the OpenMOSS
MOSS-TTS-Local v1.5 text-to-speech model (GPT-J local transformer decoded through
MOSS-Audio-Tokenizer-v2), producing 48 kHz stereo audio with optional
reference-audio voice cloning. Mirrors the qwen3-tts-cpp backend: dlopen the
static-ggml shared library, bind the moss-tts.cpp C-API via purego, and serve
the gRPC TTS method. A thin C shim holds the pipeline handle and copies engine
PCM into a Go-freeable buffer.

Wires the CI registration: backend-matrix.yml (CPU, CUDA 12/13, Intel SYCL
f16/f32, Vulkan, ROCm, NVIDIA L4T, plus Darwin metal), backend/index.yaml metas
and image entries pointing at mudler/MOSS-TTS-Local-Transformer-v1.5-GGUF, the
root Makefile build targets, and the changed-backends.js path mapping.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: list the moss-tts-cpp backend among the LocalAI-maintained engines

Add moss-tts.cpp to the README "Backends built by us" table, the
Text-to-Speech compatibility table, and the reference-audio voice-cloning
backend list, so the new backend is documented alongside its peers.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* backend(moss-tts-cpp): pin moss-tts.cpp to the squashed single-commit release

moss-tts.cpp history was collapsed to a single commit; repoint MOSSTTS_CPP_VERSION
to ee722b8e9205ee9b1b1c398a4e87e4e393e9be41.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* backend(moss-tts-cpp): add the moss-tts-cpp-development gallery meta

The gallery had the -development image entries but no matching -development
meta anchor (as locate-anything-cpp and depth-anything-cpp have), so the master
build was not installable as a gallery backend. Add moss-tts-cpp-development
mirroring the production meta with the -development capability image names.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
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>
2026-07-17 09:26:12 +02:00

125 lines
4.1 KiB
Go

package main
import (
"bytes"
"encoding/binary"
"os"
"path/filepath"
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestMossTtsCpp(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "moss-tts-cpp suite")
}
var _ = Describe("resolveVoice", func() {
It("treats an audio path as a clone reference (case-insensitive ext)", func() {
Expect(resolveVoice("/x/ref.WAV")).To(Equal("/x/ref.WAV"))
})
It("recognizes mp3/flac/ogg/m4a", func() {
for _, p := range []string{"a.mp3", "b.flac", "c.ogg", "d.m4a"} {
Expect(resolveVoice(p)).To(Equal(p))
}
})
It("ignores a bare token (no named speakers in MOSS-TTS-Local)", func() {
Expect(resolveVoice("serena")).To(BeEmpty())
})
It("returns empty for empty input", func() {
Expect(resolveVoice(" ")).To(BeEmpty())
})
})
var _ = Describe("parseOptions", func() {
It("extracts codec, tokenizer, seed", func() {
o := parseOptions([]string{
"codec:codec.gguf", "tokenizer:tok.gguf", "seed:7", "unknown:ignored",
})
Expect(o.codecPath).To(Equal("codec.gguf"))
Expect(o.tokenizerPath).To(Equal("tok.gguf"))
Expect(o.seed).To(Equal(7))
})
It("accepts audio_tokenizer / text_tokenizer aliases", func() {
o := parseOptions([]string{"audio_tokenizer:c.gguf", "text_tokenizer:t.gguf"})
Expect(o.codecPath).To(Equal("c.gguf"))
Expect(o.tokenizerPath).To(Equal("t.gguf"))
})
It("defaults seed -1", func() {
Expect(parseOptions(nil).seed).To(Equal(-1))
})
})
var _ = Describe("isAudioCodecName", func() {
DescribeTable("distinguishes the audio codec from the text tokenizer",
func(name string, want bool) {
Expect(isAudioCodecName(name)).To(Equal(want))
},
Entry("audio tokenizer is the codec", "moss-audio-tokenizer-v2-f32.gguf", true),
Entry("codec keyword", "some-codec.gguf", true),
Entry("text tokenizer is not the codec", "moss-tokenizer-v1_5.gguf", false),
Entry("model is not the codec", "moss-tts-local-v1_5-q8_0.gguf", false),
)
})
var _ = Describe("discoverCodec / discoverTokenizer", func() {
var dir, model, codec, tok string
BeforeEach(func() {
dir = GinkgoT().TempDir()
model = filepath.Join(dir, "moss-tts-local-v1_5-q8_0.gguf")
codec = filepath.Join(dir, "moss-audio-tokenizer-v2-f32.gguf")
tok = filepath.Join(dir, "moss-tokenizer-v1_5.gguf")
for _, p := range []string{model, codec, tok} {
Expect(os.WriteFile(p, []byte("x"), 0o644)).To(Succeed())
}
})
It("finds the audio codec, excluding the model", func() {
Expect(discoverCodec(dir, model)).To(Equal(codec))
})
It("finds the text tokenizer, excluding model and codec", func() {
Expect(discoverTokenizer(dir, model, codec)).To(Equal(tok))
})
})
var _ = Describe("wavHeaderStereo", func() {
It("emits a 44-byte streaming WAV header at 48 kHz stereo 16-bit", func() {
h := wavHeaderStereo(48000)
Expect(h).To(HaveLen(44))
Expect(string(h[0:4])).To(Equal("RIFF"))
Expect(string(h[8:12])).To(Equal("WAVE"))
Expect(string(h[12:16])).To(Equal("fmt "))
Expect(string(h[36:40])).To(Equal("data"))
var channels uint16
Expect(binary.Read(bytes.NewReader(h[22:24]), binary.LittleEndian, &channels)).To(Succeed())
Expect(channels).To(Equal(uint16(2)))
var sampleRate uint32
Expect(binary.Read(bytes.NewReader(h[24:28]), binary.LittleEndian, &sampleRate)).To(Succeed())
Expect(sampleRate).To(Equal(uint32(48000)))
})
It("falls back to 48 kHz for a non-positive sample rate", func() {
var sampleRate uint32
h := wavHeaderStereo(0)
Expect(binary.Read(bytes.NewReader(h[24:28]), binary.LittleEndian, &sampleRate)).To(Succeed())
Expect(sampleRate).To(Equal(uint32(48000)))
})
})
var _ = Describe("floatToPCM16LE", func() {
It("clamps and converts float PCM to little-endian int16 bytes", func() {
b := floatToPCM16LE([]float32{0, 1.0, -1.0, 2.0, -2.0})
Expect(b).To(HaveLen(10))
read := func(off int) int16 {
var v int16
_ = binary.Read(bytes.NewReader(b[off:off+2]), binary.LittleEndian, &v)
return v
}
Expect(read(0)).To(Equal(int16(0)))
Expect(read(2)).To(Equal(int16(32767)))
Expect(read(4)).To(Equal(int16(-32767)))
Expect(read(6)).To(Equal(int16(32767))) // clamped from 2.0
Expect(read(8)).To(Equal(int16(-32767))) // clamped from -2.0
})
})