mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
* fix(tts): forward the OpenAI speed field to the backend (#11097) /v1/audio/speech accepted the documented OpenAI `speed` field and then dropped it: schema.TTSRequest had no Speed member, so the value never reached proto.TTSRequest and the request returned 200 with an unchanged playback rate. Accept speed and normalise it into the existing per-request params map, which core/backend forwards verbatim to the backend. An explicit params["speed"] still wins, and a value outside the documented 0.25-4.0 range is now rejected with 400 instead of being silently ignored. Signed-off-by: Anai-Guo <antai12232931@outlook.com> * fix(tts): distinguish explicit speed=0 from an omitted field Make TTSRequest.Speed a *float32 so an explicit `"speed": 0` (invalid, below the documented 0.25 minimum) is rejected with 400 instead of being treated as unset and silently defaulted. An omitted field stays nil and leaves the backend default untouched. Add a request-boundary regression that distinguishes an omitted speed from an explicit zero, addressing review feedback. Signed-off-by: Anai-Guo <antai12232931@outlook.com> * docs: drop the speed field from the TTS docs Per review: no backend consumes params.speed today, so documenting it would be misleading. The API-level plumbing and validation stay. Signed-off-by: Anai-Guo <antai12232931@outlook.com> --------- Signed-off-by: Anai-Guo <antai12232931@outlook.com>
This commit is contained in:
@@ -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 != "" {
|
||||
|
||||
46
core/http/endpoints/localai/tts_speed.go
Normal file
46
core/http/endpoints/localai/tts_speed.go
Normal file
@@ -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
|
||||
}
|
||||
86
core/http/endpoints/localai/tts_speed_test.go
Normal file
86
core/http/endpoints/localai/tts_speed_test.go
Normal file
@@ -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"),
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user