package main import ( "bytes" "encoding/binary" "fmt" "os" "path/filepath" "strconv" "strings" "sync" laudio "github.com/mudler/LocalAI/pkg/audio" "github.com/mudler/LocalAI/pkg/grpc/base" pb "github.com/mudler/LocalAI/pkg/grpc/proto" ) // onnxProvider is set via -ldflags "-X main.onnxProvider=cuda" by the // CUDA build (later phase). Defaults to CPU. var onnxProvider = "cpu" // Per-model generation defaults, overridable via ModelOptions.Options: // // supertonic.steps= denoising steps (quality), default 8 // supertonic.speed= speech rate, default 1.05 // supertonic.silence= inter-chunk silence seconds, default 0.3 // supertonic.default_voice= voice-style used when request omits voice // supertonic.default_lang= language tag used when request omits it const ( optionSteps = "supertonic.steps=" optionSpeed = "supertonic.speed=" optionSilence = "supertonic.silence=" optionDefaultVoice = "supertonic.default_voice=" optionDefaultLang = "supertonic.default_lang=" ) type SupertonicBackend struct { base.SingleThread tts *TextToSpeech cfg Config modelDir string voicesDir string defaultVoice string defaultLang string steps int speed float32 silence float32 styleMu sync.Mutex styles map[string]*Style // voice name -> loaded style cache } func (s *SupertonicBackend) Load(opts *pb.ModelOptions) error { modelDir, err := resolveModelDir(opts.ModelFile) if err != nil { return err } s.modelDir = modelDir s.voicesDir = resolveVoicesDir(modelDir) cfg, err := LoadCfgs(modelDir) if err != nil { return fmt.Errorf("loading tts.json from %s: %w", modelDir, err) } s.cfg = cfg // onnxProvider is "cpu" for the CPU build; the CUDA build sets it to // "cuda" via -ldflags. Upstream LoadTextToSpeech still errors on GPU // until the CUDA phase wires the execution provider. tts, err := LoadTextToSpeech(modelDir, onnxProvider == "cuda", cfg) if err != nil { return fmt.Errorf("loading supertonic models from %s: %w", modelDir, err) } s.tts = tts s.steps = int(findOptionInt(opts, optionSteps, 8)) s.speed = findOptionFloat(opts, optionSpeed, 1.05) s.silence = findOptionFloat(opts, optionSilence, 0.3) s.defaultVoice = findOptionValue(opts, optionDefaultVoice, "") s.defaultLang = findOptionValue(opts, optionDefaultLang, "na") s.styles = map[string]*Style{} return nil } func (s *SupertonicBackend) TTS(req *pb.TTSRequest) error { wav, sr, err := s.synthesize(req) if err != nil { return err } out := make([]float64, len(wav)) for i, v := range wav { out[i] = float64(v) } if err := writeWavFile(req.Dst, out, sr); err != nil { return fmt.Errorf("writing wav to %s: %w", req.Dst, err) } return nil } func (s *SupertonicBackend) TTSStream(req *pb.TTSRequest, results chan []byte) error { defer close(results) wav, sr, err := s.synthesize(req) if err != nil { return err } results <- streamingWAVHeader(uint32(sr)) const chunkSamples = 4096 for off := 0; off < len(wav); off += chunkSamples { end := off + chunkSamples if end > len(wav) { end = len(wav) } results <- pcmFloatToInt16LE(wav[off:end]) } return nil } // synthesize runs the full pipeline and returns the trimmed mono float32 // PCM and its sample rate. func (s *SupertonicBackend) synthesize(req *pb.TTSRequest) ([]float32, int, error) { if s.tts == nil { return nil, 0, fmt.Errorf("supertonic model not loaded") } if strings.TrimSpace(req.Text) == "" { return nil, 0, fmt.Errorf("empty text") } style, err := s.loadStyle(s.voiceName(req.Voice)) if err != nil { return nil, 0, err } lang := s.resolveLang("") if req.Language != nil { lang = s.resolveLang(*req.Language) } wav, dur, err := s.tts.Call(req.Text, lang, style, s.steps, s.speed, s.silence) if err != nil { return nil, 0, err } sr := s.tts.SampleRate // Call returns concatenated audio; trim to the reported duration. wavLen := int(float32(sr) * dur) if wavLen < 0 { wavLen = 0 } if wavLen > len(wav) { wavLen = len(wav) } return wav[:wavLen], sr, nil } // voiceName picks the request voice, falling back to the model default. func (s *SupertonicBackend) voiceName(reqVoice string) string { v := strings.TrimSpace(reqVoice) if v == "" { return s.defaultVoice } return v } // resolveLang validates against AvailableLangs, falling back to the model // default (then "na"). func (s *SupertonicBackend) resolveLang(reqLang string) string { l := strings.TrimSpace(reqLang) if l != "" && isValidLang(l) { return l } if s.defaultLang != "" && isValidLang(s.defaultLang) { return s.defaultLang } return "na" } // loadStyle resolves and caches a voice-style. An empty name with no model // default is an error (supertonic requires a style embedding). func (s *SupertonicBackend) loadStyle(name string) (*Style, error) { if name == "" { return nil, fmt.Errorf("no voice specified and no supertonic.default_voice set") } s.styleMu.Lock() defer s.styleMu.Unlock() if st, ok := s.styles[name]; ok { return st, nil } path := s.voiceStylePath(name) st, err := LoadVoiceStyle([]string{path}, false) if err != nil { return nil, fmt.Errorf("loading voice style %q (%s): %w", name, path, err) } s.styles[name] = st return st, nil } // voiceStylePath maps a voice name to a JSON path. Absolute paths are honored; // names containing a separator resolve under modelDir; bare names resolve under // the resolved voicesDir (see resolveVoicesDir). func (s *SupertonicBackend) voiceStylePath(name string) string { if !strings.HasSuffix(name, ".json") { name += ".json" } if filepath.IsAbs(name) { return name } if strings.ContainsRune(name, filepath.Separator) { return filepath.Join(s.modelDir, name) } return filepath.Join(s.voicesDir, name) } // resolveVoicesDir locates the voice_styles directory. The HF model layout // puts the ONNX files in an onnx/ subdir with voice_styles/ as its sibling, // so check modelDir/voice_styles first, then the parent's voice_styles. func resolveVoicesDir(modelDir string) string { candidates := []string{ filepath.Join(modelDir, "voice_styles"), filepath.Join(filepath.Dir(modelDir), "voice_styles"), } for _, c := range candidates { if info, err := os.Stat(c); err == nil && info.IsDir() { return c } } return candidates[0] } // resolveModelDir accepts either a directory (used as-is) or a file (its // parent dir is used). func resolveModelDir(modelFile string) (string, error) { if modelFile == "" { return "", fmt.Errorf("empty model path") } info, err := os.Stat(modelFile) if err != nil { return "", fmt.Errorf("stat model path %s: %w", modelFile, err) } if info.IsDir() { return modelFile, nil } return filepath.Dir(modelFile), nil } // ---- option helpers (mirrors backend/go/sherpa-onnx/backend.go) ---- func findOptionValue(opts *pb.ModelOptions, prefix, def string) string { for _, o := range opts.Options { if strings.HasPrefix(o, prefix) { return strings.TrimPrefix(o, prefix) } } return def } func findOptionFloat(opts *pb.ModelOptions, prefix string, def float32) float32 { raw := findOptionValue(opts, prefix, "") if raw == "" { return def } v, err := strconv.ParseFloat(raw, 32) if err != nil { return def } return float32(v) } func findOptionInt(opts *pb.ModelOptions, prefix string, def int32) int32 { raw := findOptionValue(opts, prefix, "") if raw == "" { return def } v, err := strconv.ParseInt(raw, 10, 32) if err != nil { return def } return int32(v) } // ---- PCM helpers ---- func pcmFloatToInt16LE(samples []float32) []byte { buf := make([]byte, len(samples)*2) for i, f := range samples { v := int32(f * 32767) if v > 32767 { v = 32767 } else if v < -32768 { v = -32768 } binary.LittleEndian.PutUint16(buf[2*i:], uint16(int16(v))) } return buf } func streamingWAVHeader(sampleRate uint32) []byte { const streamingSize = 0xFFFFFFFF h := laudio.NewWAVHeaderWithRate(streamingSize, sampleRate) h.ChunkSize = streamingSize var buf bytes.Buffer _ = h.Write(&buf) return buf.Bytes() }