mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
* 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>
225 lines
7.0 KiB
Go
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
|
|
}
|