diff --git a/core/http/endpoints/localai/tts.go b/core/http/endpoints/localai/tts.go index ad3e4a525..16b49a912 100644 --- a/core/http/endpoints/localai/tts.go +++ b/core/http/endpoints/localai/tts.go @@ -40,6 +40,10 @@ func TTSEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig return echo.ErrBadRequest } + if err := applyTTSSpeed(input); err != nil { + return err + } + xlog.Debug("LocalAI TTS Request received", "model", input.Model) if cfg.Backend == "" && input.Backend != "" { diff --git a/core/http/endpoints/localai/tts_speed.go b/core/http/endpoints/localai/tts_speed.go new file mode 100644 index 000000000..9d2125371 --- /dev/null +++ b/core/http/endpoints/localai/tts_speed.go @@ -0,0 +1,46 @@ +package localai + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/schema" +) + +// ttsSpeedMin and ttsSpeedMax mirror the range the OpenAI Speech API documents +// for the `speed` field. +const ( + ttsSpeedMin = 0.25 + ttsSpeedMax = 4.0 +) + +// applyTTSSpeed normalises the OpenAI `speed` field onto the backend-specific +// params map, which is what actually reaches the gRPC TTSRequest. Without this +// the field is parsed off the request and then dropped, so a call with +// speed=0.8 returns HTTP 200 with an unchanged playback rate instead of either +// honouring or rejecting it (#11097). +// +// An explicit `"speed": 0` is invalid (below the documented minimum) and is +// rejected with 400 rather than treated as unset, which is why Speed is a +// pointer. An explicit params["speed"] wins so the LocalAI-native extension keeps +// precedence over the OpenAI-compatible field. Backends whose model exposes no +// rate control ignore the param, exactly like they ignore `instructions`. +func applyTTSSpeed(input *schema.TTSRequest) error { + if input.Speed == nil { + return nil + } + speed := *input.Speed + if speed < ttsSpeedMin || speed > ttsSpeedMax { + return echo.NewHTTPError(http.StatusBadRequest, + fmt.Sprintf("speed must be between %g and %g", ttsSpeedMin, ttsSpeedMax)) + } + if input.Params == nil { + input.Params = make(map[string]string) + } + if _, ok := input.Params["speed"]; !ok { + input.Params["speed"] = strconv.FormatFloat(float64(speed), 'g', -1, 32) + } + return nil +} diff --git a/core/http/endpoints/localai/tts_speed_test.go b/core/http/endpoints/localai/tts_speed_test.go new file mode 100644 index 000000000..824a52444 --- /dev/null +++ b/core/http/endpoints/localai/tts_speed_test.go @@ -0,0 +1,86 @@ +package localai + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/schema" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// f32 returns a pointer to v, matching how the OpenAI `speed` field is decoded +// (a pointer so an explicit zero is distinguishable from an omitted field). +func f32(v float32) *float32 { return &v } + +// Regression for #11097: /v1/audio/speech accepted the documented OpenAI +// `speed` field, dropped it before the request reached the backend, and +// returned 200 with an unchanged playback rate. The field must now be +// normalised onto the params map that is forwarded to the gRPC TTSRequest, +// and an out-of-range value must be rejected instead of silently ignored. +var _ = Describe("applyTTSSpeed", func() { + It("forwards speed onto the backend params map", func() { + input := &schema.TTSRequest{Speed: f32(0.8)} + Expect(applyTTSSpeed(input)).To(Succeed()) + Expect(input.Params).To(HaveKeyWithValue("speed", "0.8")) + }) + + It("leaves params untouched when speed is unset", func() { + input := &schema.TTSRequest{} + Expect(applyTTSSpeed(input)).To(Succeed()) + Expect(input.Params).To(BeNil()) + }) + + It("treats an omitted speed as unset but rejects an explicit zero", func() { + omitted := &schema.TTSRequest{} + Expect(applyTTSSpeed(omitted)).To(Succeed()) + Expect(omitted.Params).To(BeNil()) + + explicitZero := &schema.TTSRequest{Speed: f32(0)} + err := applyTTSSpeed(explicitZero) + Expect(err).To(HaveOccurred()) + httpErr, ok := err.(*echo.HTTPError) + Expect(ok).To(BeTrue()) + Expect(httpErr.Code).To(Equal(http.StatusBadRequest)) + Expect(explicitZero.Params).To(BeNil()) + }) + + It("keeps an explicit params entry over the OpenAI field", func() { + input := &schema.TTSRequest{Speed: f32(0.8), Params: map[string]string{"speed": "1.5"}} + Expect(applyTTSSpeed(input)).To(Succeed()) + Expect(input.Params).To(HaveKeyWithValue("speed", "1.5")) + }) + + It("preserves unrelated params", func() { + input := &schema.TTSRequest{Speed: f32(2), Params: map[string]string{"ref_text": "hello"}} + Expect(applyTTSSpeed(input)).To(Succeed()) + Expect(input.Params).To(HaveKeyWithValue("ref_text", "hello")) + Expect(input.Params).To(HaveKeyWithValue("speed", "2")) + }) + + DescribeTable("rejects values outside the OpenAI range", + func(speed float32) { + input := &schema.TTSRequest{Speed: f32(speed)} + err := applyTTSSpeed(input) + Expect(err).To(HaveOccurred()) + httpErr, ok := err.(*echo.HTTPError) + Expect(ok).To(BeTrue()) + Expect(httpErr.Code).To(Equal(http.StatusBadRequest)) + Expect(input.Params).To(BeNil()) + }, + Entry("below the minimum", float32(0.1)), + Entry("above the maximum", float32(4.5)), + Entry("negative", float32(-1)), + Entry("explicit zero (distinct from an omitted field)", float32(0)), + ) + + DescribeTable("accepts the documented bounds", + func(speed float32, want string) { + input := &schema.TTSRequest{Speed: f32(speed)} + Expect(applyTTSSpeed(input)).To(Succeed()) + Expect(input.Params).To(HaveKeyWithValue("speed", want)) + }, + Entry("minimum", float32(0.25), "0.25"), + Entry("maximum", float32(4), "4"), + ) +}) diff --git a/core/schema/localai.go b/core/schema/localai.go index 47d71ec00..6513a7013 100644 --- a/core/schema/localai.go +++ b/core/schema/localai.go @@ -80,6 +80,12 @@ type TTSRequest struct { Format string `json:"response_format,omitempty" yaml:"response_format,omitempty"` // (optional) output format Stream bool `json:"stream,omitempty" yaml:"stream,omitempty"` // (optional) enable streaming TTS SampleRate int `json:"sample_rate,omitempty" yaml:"sample_rate,omitempty"` // (optional) desired output sample rate + // Speed is the OpenAI `speed` field (0.25-4.0). It is a pointer so an + // explicit `"speed": 0` (invalid, rejected with 400) is distinguishable + // from an omitted field (left at the backend default). It is normalised + // into Params["speed"] so it reaches the backend over the same channel as + // the other per-request generation parameters. + Speed *float32 `json:"speed,omitempty" yaml:"speed,omitempty"` // Instructions is a free-form, per-request style/voice description. It maps to // the OpenAI `instructions` field and is forwarded to the backend so expressive // TTS models (e.g. Qwen3-TTS CustomVoice/VoiceDesign) can vary tone or designed