Files
LocalAI/backend/go/moss-tts-cpp/gomossttscpp.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

225 lines
7.0 KiB
Go

package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"unsafe"
"github.com/mudler/LocalAI/pkg/grpc/base"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/xlog"
)
var (
// mtl_load(local_path, codec_path, tokenizer_path) int
CppLoad func(localPath, codecPath, tokenizerPath string) int
// mtl_tts(text, reference_wav, seed, out_n, out_sr) -> float*
CppTTS func(text, referenceWav string, seed int, outN, outSR unsafe.Pointer) uintptr
// mtl_pcm_free(ptr)
CppPCMFree func(ptr uintptr)
// mtl_unload()
CppUnload func()
)
type MossTtsCpp struct {
base.SingleThread
opts loadOptions
// audioPath is the model-config reference voice (tts.audio_path), the
// default clone reference when a request omits an audio Voice.
audioPath string
}
func (m *MossTtsCpp) 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)
dir := filepath.Dir(model)
// Resolve the codec GGUF (MOSS-Audio-Tokenizer): explicit option, else
// auto-discover an *audio*tokenizer*/codec sibling of the model.
codec := resolveAux(m.opts.codecPath, dir)
if codec == "" {
codec = discoverCodec(dir, model)
}
if codec == "" {
return fmt.Errorf("moss-tts: no codec GGUF found; set option 'codec:<file>'")
}
m.opts.codecPath = codec
// Resolve the text tokenizer GGUF: explicit option, else auto-discover the
// *tokenizer* sibling that is not the audio codec or the model.
tokenizer := resolveAux(m.opts.tokenizerPath, dir)
if tokenizer == "" {
tokenizer = discoverTokenizer(dir, model, codec)
}
if tokenizer == "" {
return fmt.Errorf("moss-tts: no tokenizer GGUF found; set option 'tokenizer:<file>'")
}
m.opts.tokenizerPath = tokenizer
m.audioPath = opts.AudioPath
if m.audioPath != "" && !filepath.IsAbs(m.audioPath) {
m.audioPath = filepath.Join(dir, m.audioPath)
}
xlog.Info("[moss-tts-cpp] Load", "model", model, "codec", codec, "tokenizer", tokenizer)
if rc := CppLoad(model, codec, tokenizer); rc != 0 {
return fmt.Errorf("moss-tts: failed to load model (rc=%d)", rc)
}
return nil
}
// resolveAux resolves an explicitly configured auxiliary GGUF path relative to
// the model directory when it is not absolute. Empty stays empty.
func resolveAux(p, dir string) string {
if p == "" || filepath.IsAbs(p) {
return p
}
return filepath.Join(dir, p)
}
// isAudioCodecName reports whether a GGUF filename denotes the MOSS-Audio-
// Tokenizer codec (e.g. moss-audio-tokenizer-v2-f32.gguf) rather than the text
// tokenizer (moss-tokenizer-v1_5.gguf).
func isAudioCodecName(name string) bool {
n := strings.ToLower(name)
return strings.Contains(n, "codec") ||
(strings.Contains(n, "audio") && strings.Contains(n, "tokenizer"))
}
// discoverCodec returns the first codec GGUF in dir (excluding the model), or "".
func discoverCodec(dir, model string) string {
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
modelBase := filepath.Base(model)
for _, e := range entries {
name := e.Name()
if name == modelBase || !strings.HasSuffix(strings.ToLower(name), ".gguf") {
continue
}
if isAudioCodecName(name) {
return filepath.Join(dir, name)
}
}
return ""
}
// discoverTokenizer returns the first text-tokenizer GGUF in dir that is neither
// the model nor the audio codec, or "".
func discoverTokenizer(dir, model, codec string) string {
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
modelBase := filepath.Base(model)
codecBase := filepath.Base(codec)
for _, e := range entries {
name := e.Name()
lower := strings.ToLower(name)
if name == modelBase || name == codecBase || !strings.HasSuffix(lower, ".gguf") {
continue
}
if strings.Contains(lower, "tokenizer") && !isAudioCodecName(name) {
return filepath.Join(dir, name)
}
}
return ""
}
// resolveRequest derives the synthesis inputs from a TTSRequest: the optional
// clone-reference WAV path and the seed. MOSS-TTS-Local drives cloning purely
// from a reference-audio path (the engine decodes it), so there is no
// language / speaker / instruct plumbing here (unlike qwen3-tts-cpp).
func (m *MossTtsCpp) resolveRequest(req *pb.TTSRequest) (refPath string, seed int) {
refPath = resolveVoice(req.Voice)
if refPath == "" && m.audioPath != "" {
// No per-request voice: fall back to the config clone reference.
refPath = m.audioPath
}
if refPath != "" && !filepath.IsAbs(refPath) {
refPath = filepath.Join(filepath.Dir(m.opts.codecPath), refPath)
}
seed = m.opts.seed
if req.Params != nil {
seed = parseInt(req.Params["seed"], seed)
}
return
}
func (m *MossTtsCpp) TTS(req *pb.TTSRequest) error {
if req.Dst == "" {
return fmt.Errorf("moss-tts: TTS requires a destination path")
}
if req.Text == "" {
return fmt.Errorf("moss-tts: TTS requires text")
}
refPath, seed := m.resolveRequest(req)
var n, sr int32
ptr := CppTTS(req.Text, refPath, seed, unsafe.Pointer(&n), unsafe.Pointer(&sr)) // #nosec G103 -- out-params for the purego-bound C-API
if ptr == 0 {
return fmt.Errorf("moss-tts: synthesis failed")
}
// 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 CppPCMFree(ptr)
if n <= 0 {
return fmt.Errorf("moss-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 writeWAVStereo(req.Dst, out, int(sr))
}
// TTSStream synthesizes one-shot (MOSS-TTS-Local has no native streaming C-API)
// and then emits a self-describing stereo WAV: a header chunk followed by the
// interleaved 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 *MossTtsCpp) TTSStream(req *pb.TTSRequest, results chan []byte) error {
defer close(results)
if req.Text == "" {
return fmt.Errorf("moss-tts: TTSStream requires text")
}
refPath, seed := m.resolveRequest(req)
var n, sr int32
ptr := CppTTS(req.Text, refPath, seed, unsafe.Pointer(&n), unsafe.Pointer(&sr)) // #nosec G103 -- out-params for the purego-bound C-API
if ptr == 0 {
return fmt.Errorf("moss-tts: synthesis failed")
}
defer CppPCMFree(ptr)
if n <= 0 {
return fmt.Errorf("moss-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)
results <- wavHeaderStereo(int(sr))
const frameChunk = 4096 // interleaved stereo samples per emitted chunk
for off := 0; off < len(out); off += frameChunk {
end := off + frameChunk
if end > len(out) {
end = len(out)
}
results <- floatToPCM16LE(out[off:end])
}
return nil
}