mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-07-28 01:27:58 +00:00
fix(tts): support OpenRouter voice overrides and fallback
This commit is contained in:
@@ -455,7 +455,7 @@ Use `mode = lines` when:
|
|||||||
"tools": {
|
"tools": {
|
||||||
"read_file": {
|
"read_file": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"mode": "lines",
|
"mode": "lines",
|
||||||
"max_read_file_size": 65536
|
"max_read_file_size": 65536
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -838,6 +838,9 @@ Legacy Telegram environment variables remain compatible: `PICOCLAW_CHANNELS_TELE
|
|||||||
|
|
||||||
Failure behavior is intentionally conservative: if streaming fails before any visible chunk is sent, PicoClaw retries once through the normal `Chat()` path. If a chunk has already been shown to the user, PicoClaw does not send a second non-streaming answer, because that would duplicate visible output.
|
Failure behavior is intentionally conservative: if streaming fails before any visible chunk is sent, PicoClaw retries once through the normal `Chat()` path. If a chunk has already been shown to the user, PicoClaw does not send a second non-streaming answer, because that would duplicate visible output.
|
||||||
|
|
||||||
|
For model-specific TTS request fields such as custom speech `voice` names or
|
||||||
|
`response_format: "mp3"`, use `model_list[].extra_body`.
|
||||||
|
|
||||||
#### Vendor-Specific Examples
|
#### Vendor-Specific Examples
|
||||||
|
|
||||||
> **Tip**: You can omit `api_key` fields and store them in `.security.yml` for better security. See [Security Configuration](#-security-configuration-recommended).
|
> **Tip**: You can omit `api_key` fields and store them in `.security.yml` for better security. See [Security Configuration](#-security-configuration-recommended).
|
||||||
@@ -856,6 +859,35 @@ Failure behavior is intentionally conservative: if streaming fails before any vi
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>OpenRouter TTS (MAI Voice 2)</b></summary>
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "mai-voice-2",
|
||||||
|
"provider": "openrouter",
|
||||||
|
"model": "microsoft/mai-voice-2",
|
||||||
|
"api_base": "https://openrouter.ai/api/v1",
|
||||||
|
"extra_body": {
|
||||||
|
"voice": "en-US-Harper:MAI-Voice-2",
|
||||||
|
"response_format": "mp3"
|
||||||
|
}
|
||||||
|
// api_key: set in .security.yml
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pair this with:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"voice": {
|
||||||
|
"tts_model_name": "mai-voice-2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>VolcEngine (Doubao)</b></summary>
|
<summary><b>VolcEngine (Doubao)</b></summary>
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,9 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||||||
|
|
||||||
When streaming is disabled, omit the `streaming` block. Writing `"streaming": {"enabled": false}` is optional and not needed in generated or hand-written config.
|
When streaming is disabled, omit the `streaming` block. Writing `"streaming": {"enabled": false}` is optional and not needed in generated or hand-written config.
|
||||||
|
|
||||||
|
`extra_body` is especially useful for model-specific TTS fields on OpenAI-compatible
|
||||||
|
speech routes, for example custom `voice` names or `response_format: "mp3"`.
|
||||||
|
|
||||||
#### Tool Schema Compatibility
|
#### Tool Schema Compatibility
|
||||||
|
|
||||||
By default, PicoClaw now forwards tool JSON Schemas unchanged.
|
By default, PicoClaw now forwards tool JSON Schemas unchanged.
|
||||||
@@ -205,6 +208,41 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Voice Synthesis
|
||||||
|
|
||||||
|
You can configure a dedicated text-to-speech model with `voice.tts_model_name`.
|
||||||
|
When the provider needs model-specific TTS request fields, put them in
|
||||||
|
`model_list[].extra_body`.
|
||||||
|
|
||||||
|
Example with OpenRouter `microsoft/mai-voice-2`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "mai-voice-2",
|
||||||
|
"provider": "openrouter",
|
||||||
|
"model": "microsoft/mai-voice-2",
|
||||||
|
"api_base": "https://openrouter.ai/api/v1",
|
||||||
|
"extra_body": {
|
||||||
|
"voice": "en-US-Harper:MAI-Voice-2",
|
||||||
|
"response_format": "mp3"
|
||||||
|
},
|
||||||
|
"api_keys": ["sk-or-your-openrouter-key"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"voice": {
|
||||||
|
"tts_model_name": "mai-voice-2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes that matter:
|
||||||
|
|
||||||
|
- PicoClaw still uses the OpenAI-compatible `/audio/speech` route for this setup.
|
||||||
|
- The default TTS request uses `voice: alloy` and `response_format: opus`.
|
||||||
|
- Override those defaults with `extra_body` when your selected TTS model requires different values.
|
||||||
|
|
||||||
#### Vendor-Specific Examples
|
#### Vendor-Specific Examples
|
||||||
|
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
|
|||||||
+45
-2
@@ -25,6 +25,8 @@ Instead:
|
|||||||
|
|
||||||
- `voice.tts_model_name` selects a named entry from `model_list`.
|
- `voice.tts_model_name` selects a named entry from `model_list`.
|
||||||
- That `model_list` entry provides the provider, model ID, API base, and proxy settings.
|
- That `model_list` entry provides the provider, model ID, API base, and proxy settings.
|
||||||
|
- For providers that need model-specific TTS parameters, use `model_list[].extra_body`
|
||||||
|
to pass fields such as `voice` and `response_format`.
|
||||||
- `.security.yml` stores the API key for the same named model entry.
|
- `.security.yml` stores the API key for the same named model entry.
|
||||||
|
|
||||||
This is the recommended and supported configuration pattern.
|
This is the recommended and supported configuration pattern.
|
||||||
@@ -87,6 +89,43 @@ model_list:
|
|||||||
|
|
||||||
If you use a custom MiMo endpoint, you can also set `api_base` explicitly. Otherwise PicoClaw will use the provider default.
|
If you use a custom MiMo endpoint, you can also set `api_base` explicitly. Otherwise PicoClaw will use the provider default.
|
||||||
|
|
||||||
|
### Option C: OpenRouter MAI Voice 2
|
||||||
|
|
||||||
|
Some OpenAI-compatible TTS routes require provider-specific request fields.
|
||||||
|
OpenRouter's `microsoft/mai-voice-2` is one example: it needs a model-specific
|
||||||
|
voice name and works best with `response_format: "mp3"`.
|
||||||
|
|
||||||
|
`config.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"voice": {
|
||||||
|
"tts_model_name": "mai-voice-2"
|
||||||
|
},
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "mai-voice-2",
|
||||||
|
"provider": "openrouter",
|
||||||
|
"model": "microsoft/mai-voice-2",
|
||||||
|
"api_base": "https://openrouter.ai/api/v1",
|
||||||
|
"extra_body": {
|
||||||
|
"voice": "en-US-Harper:MAI-Voice-2",
|
||||||
|
"response_format": "mp3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`.security.yml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
mai-voice-2:
|
||||||
|
api_keys:
|
||||||
|
- "sk-or-your-openrouter-key"
|
||||||
|
```
|
||||||
|
|
||||||
## What PicoClaw Sends Today
|
## What PicoClaw Sends Today
|
||||||
|
|
||||||
The current TTS runtime uses an OpenAI-compatible speech request with these defaults:
|
The current TTS runtime uses an OpenAI-compatible speech request with these defaults:
|
||||||
@@ -96,11 +135,14 @@ The current TTS runtime uses an OpenAI-compatible speech request with these defa
|
|||||||
- Voice: `alloy`
|
- Voice: `alloy`
|
||||||
- Model: taken from the selected `model_list` entry
|
- Model: taken from the selected `model_list` entry
|
||||||
|
|
||||||
|
These defaults can now be overridden per model through `model_list[].extra_body`.
|
||||||
|
|
||||||
That means:
|
That means:
|
||||||
|
|
||||||
- `openai/tts-1` works naturally.
|
- `openai/tts-1` works naturally.
|
||||||
- Other OpenAI-compatible providers can work if they accept the same request format.
|
- Other OpenAI-compatible providers can work if they accept the same request format.
|
||||||
- PicoClaw currently does not expose a user-facing config field for changing the TTS voice from `alloy`.
|
- Provider-specific TTS models may need their own `voice` and `response_format` values.
|
||||||
|
- If a provider rejects `response_format`, PicoClaw retries once without that field.
|
||||||
|
|
||||||
## How PicoClaw Chooses a TTS Provider
|
## How PicoClaw Chooses a TTS Provider
|
||||||
|
|
||||||
@@ -124,7 +166,8 @@ PicoClaw normalizes the configured base URL for TTS:
|
|||||||
|
|
||||||
- Setting `voice.tts_model_name` to a name that does not exist in `model_list`.
|
- Setting `voice.tts_model_name` to a name that does not exist in `model_list`.
|
||||||
- Adding a TTS model but forgetting to put its API key in `.security.yml`.
|
- Adding a TTS model but forgetting to put its API key in `.security.yml`.
|
||||||
- Assuming PicoClaw will automatically use provider-specific custom voices.
|
- Assuming PicoClaw will automatically infer provider-specific custom voices.
|
||||||
|
- Forgetting to set `model_list[].extra_body.voice` or `model_list[].extra_body.response_format` for TTS models that require them.
|
||||||
- Using a provider endpoint that is not compatible with the OpenAI `/audio/speech` request format.
|
- Using a provider endpoint that is not compatible with the OpenAI `/audio/speech` request format.
|
||||||
|
|
||||||
## Minimal Checklist
|
## Minimal Checklist
|
||||||
|
|||||||
+116
-16
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -16,14 +17,49 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type OpenAITTSProvider struct {
|
type OpenAITTSProvider struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
apiBase string
|
apiBase string
|
||||||
voice string
|
voice string
|
||||||
model string
|
model string
|
||||||
httpClient *http.Client
|
responseFormat string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenAITTSOptions struct {
|
||||||
|
Voice string
|
||||||
|
ResponseFormat string
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAITTSAudioStream struct {
|
||||||
|
io.ReadCloser
|
||||||
|
fileExt string
|
||||||
|
contentType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *openAITTSAudioStream) AudioFileMeta() (string, string) {
|
||||||
|
return s.fileExt, s.contentType
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAITTSAPIError struct {
|
||||||
|
statusCode int
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *openAITTSAPIError) Error() string {
|
||||||
|
return fmt.Sprintf("API error (status %d): %s", e.statusCode, e.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOpenAITTSProvider(apiKey string, apiBase string, proxyURL string, model string) *OpenAITTSProvider {
|
func NewOpenAITTSProvider(apiKey string, apiBase string, proxyURL string, model string) *OpenAITTSProvider {
|
||||||
|
return NewOpenAITTSProviderWithOptions(apiKey, apiBase, proxyURL, model, OpenAITTSOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOpenAITTSProviderWithOptions(
|
||||||
|
apiKey string,
|
||||||
|
apiBase string,
|
||||||
|
proxyURL string,
|
||||||
|
model string,
|
||||||
|
options OpenAITTSOptions,
|
||||||
|
) *OpenAITTSProvider {
|
||||||
// Normalize apiBase to avoid malformed endpoints like
|
// Normalize apiBase to avoid malformed endpoints like
|
||||||
// "https://api.openai.com/audio/speech" when "/v1" is required.
|
// "https://api.openai.com/audio/speech" when "/v1" is required.
|
||||||
if apiBase == "" {
|
if apiBase == "" {
|
||||||
@@ -75,12 +111,23 @@ func NewOpenAITTSProvider(apiKey string, apiBase string, proxyURL string, model
|
|||||||
model = "tts-1"
|
model = "tts-1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
voice := strings.TrimSpace(options.Voice)
|
||||||
|
if voice == "" {
|
||||||
|
voice = "alloy"
|
||||||
|
}
|
||||||
|
|
||||||
|
responseFormat := strings.TrimSpace(options.ResponseFormat)
|
||||||
|
if responseFormat == "" {
|
||||||
|
responseFormat = "opus"
|
||||||
|
}
|
||||||
|
|
||||||
return &OpenAITTSProvider{
|
return &OpenAITTSProvider{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
apiBase: apiBase,
|
apiBase: apiBase,
|
||||||
voice: "alloy",
|
voice: voice,
|
||||||
model: model,
|
model: model,
|
||||||
httpClient: client,
|
responseFormat: responseFormat,
|
||||||
|
httpClient: client,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,11 +138,42 @@ func (t *OpenAITTSProvider) Name() string {
|
|||||||
func (t *OpenAITTSProvider) Synthesize(ctx context.Context, text string) (io.ReadCloser, error) {
|
func (t *OpenAITTSProvider) Synthesize(ctx context.Context, text string) (io.ReadCloser, error) {
|
||||||
logger.DebugCF("voice-tts", "Starting TTS synthesis", map[string]any{"text_len": len(text)})
|
logger.DebugCF("voice-tts", "Starting TTS synthesis", map[string]any{"text_len": len(text)})
|
||||||
|
|
||||||
|
responseFormat := t.responseFormat
|
||||||
|
resp, err := t.doSpeechRequest(ctx, text, responseFormat)
|
||||||
|
if err != nil {
|
||||||
|
var apiErr *openAITTSAPIError
|
||||||
|
if errors.As(err, &apiErr) && shouldRetryWithoutResponseFormat(apiErr.body) {
|
||||||
|
logger.InfoCF("voice-tts", "Retrying TTS without response_format after provider rejection", map[string]any{
|
||||||
|
"model": t.model,
|
||||||
|
})
|
||||||
|
responseFormat = ""
|
||||||
|
resp, err = t.doSpeechRequest(ctx, text, responseFormat)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileExt, contentType := audioFileMetaForResponseFormat(responseFormat)
|
||||||
|
return &openAITTSAudioStream{
|
||||||
|
ReadCloser: resp.Body,
|
||||||
|
fileExt: fileExt,
|
||||||
|
contentType: contentType,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *OpenAITTSProvider) doSpeechRequest(
|
||||||
|
ctx context.Context,
|
||||||
|
text string,
|
||||||
|
responseFormat string,
|
||||||
|
) (*http.Response, error) {
|
||||||
reqBody := map[string]any{
|
reqBody := map[string]any{
|
||||||
"model": t.model,
|
"model": t.model,
|
||||||
"input": text,
|
"input": text,
|
||||||
"voice": t.voice,
|
"voice": t.voice,
|
||||||
"response_format": "opus",
|
}
|
||||||
|
if responseFormat != "" {
|
||||||
|
reqBody["response_format"] = responseFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonData, err := json.Marshal(reqBody)
|
jsonData, err := json.Marshal(reqBody)
|
||||||
@@ -119,8 +197,30 @@ func (t *OpenAITTSProvider) Synthesize(ctx context.Context, text string) (io.Rea
|
|||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
return nil, &openAITTSAPIError{
|
||||||
|
statusCode: resp.StatusCode,
|
||||||
|
body: string(body),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp.Body, nil
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldRetryWithoutResponseFormat(body string) bool {
|
||||||
|
lower := strings.ToLower(body)
|
||||||
|
if !strings.Contains(lower, "response_format") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(lower, "invalid") || strings.Contains(lower, "unsupported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func audioFileMetaForResponseFormat(responseFormat string) (string, string) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(responseFormat)) {
|
||||||
|
case "", "mp3":
|
||||||
|
return ".mp3", "audio/mpeg"
|
||||||
|
case "wav":
|
||||||
|
return ".wav", "audio/wav"
|
||||||
|
default:
|
||||||
|
return ".ogg", "audio/ogg"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-1
@@ -19,6 +19,10 @@ type TTSProvider interface {
|
|||||||
Synthesize(ctx context.Context, text string) (io.ReadCloser, error)
|
Synthesize(ctx context.Context, text string) (io.ReadCloser, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ttsAudioMetaProvider interface {
|
||||||
|
AudioFileMeta() (fileExt string, contentType string)
|
||||||
|
}
|
||||||
|
|
||||||
func providerFromModelConfig(mc *config.ModelConfig) TTSProvider {
|
func providerFromModelConfig(mc *config.ModelConfig) TTSProvider {
|
||||||
if mc == nil || mc.APIKey() == "" {
|
if mc == nil || mc.APIKey() == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -33,10 +37,31 @@ func providerFromModelConfig(mc *config.ModelConfig) TTSProvider {
|
|||||||
case "mimo":
|
case "mimo":
|
||||||
return NewMimoTTSProvider(mc.APIKey(), providers.ResolveAPIBase(mc), modelID, mc.Proxy)
|
return NewMimoTTSProvider(mc.APIKey(), providers.ResolveAPIBase(mc), modelID, mc.Proxy)
|
||||||
default:
|
default:
|
||||||
return NewOpenAITTSProvider(mc.APIKey(), providers.ResolveAPIBase(mc), mc.Proxy, modelID)
|
return NewOpenAITTSProviderWithOptions(
|
||||||
|
mc.APIKey(),
|
||||||
|
providers.ResolveAPIBase(mc),
|
||||||
|
mc.Proxy,
|
||||||
|
modelID,
|
||||||
|
openAITTSOptionsFromModelConfig(mc),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func openAITTSOptionsFromModelConfig(mc *config.ModelConfig) OpenAITTSOptions {
|
||||||
|
options := OpenAITTSOptions{}
|
||||||
|
if mc == nil || mc.ExtraBody == nil {
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
if voice, ok := mc.ExtraBody["voice"].(string); ok {
|
||||||
|
options.Voice = strings.TrimSpace(voice)
|
||||||
|
}
|
||||||
|
if responseFormat, ok := mc.ExtraBody["response_format"].(string); ok {
|
||||||
|
options.ResponseFormat = strings.TrimSpace(responseFormat)
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
func DetectTTS(cfg *config.Config) TTSProvider {
|
func DetectTTS(cfg *config.Config) TTSProvider {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -100,6 +125,12 @@ func SynthesizeAndStore(
|
|||||||
fileExt = ".mp3"
|
fileExt = ".mp3"
|
||||||
contentType = "audio/mpeg"
|
contentType = "audio/mpeg"
|
||||||
}
|
}
|
||||||
|
if metaProvider, ok := stream.(ttsAudioMetaProvider); ok {
|
||||||
|
if ext, ct := metaProvider.AudioFileMeta(); ext != "" && ct != "" {
|
||||||
|
fileExt = ext
|
||||||
|
contentType = ct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
file, err := os.CreateTemp(media.TempDir(), "tts-*"+fileExt)
|
file, err := os.CreateTemp(media.TempDir(), "tts-*"+fileExt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -132,6 +132,64 @@ func TestOpenAITTSProvider_SynthesizeNon200(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenAITTSProvider_SynthesizeRetriesWithoutResponseFormat(t *testing.T) {
|
||||||
|
var requestBodies []map[string]any
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
bodyBytes, _ := io.ReadAll(r.Body)
|
||||||
|
_ = r.Body.Close()
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.Unmarshal(bodyBytes, &body)
|
||||||
|
requestBodies = append(requestBodies, body)
|
||||||
|
|
||||||
|
if len(requestBodies) == 1 {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"response_format is invalid"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("audio-bytes"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
provider := NewOpenAITTSProvider("k123", server.URL, "", "x-ai/grok-voice-tts-1.0")
|
||||||
|
stream, err := provider.Synthesize(context.Background(), "hello")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Synthesize failed: %v", err)
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(stream)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read stream failed: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "audio-bytes" {
|
||||||
|
t.Fatalf("response body mismatch: got %q", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(requestBodies) != 2 {
|
||||||
|
t.Fatalf("request count mismatch: got %d, want 2", len(requestBodies))
|
||||||
|
}
|
||||||
|
if requestBodies[0]["response_format"] != "opus" {
|
||||||
|
t.Fatalf("first request should include opus response_format, got %#v", requestBodies[0]["response_format"])
|
||||||
|
}
|
||||||
|
if _, ok := requestBodies[1]["response_format"]; ok {
|
||||||
|
t.Fatalf("second request should omit response_format, got %#v", requestBodies[1]["response_format"])
|
||||||
|
}
|
||||||
|
|
||||||
|
metaStream, ok := stream.(interface {
|
||||||
|
AudioFileMeta() (string, string)
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("stream does not expose audio metadata")
|
||||||
|
}
|
||||||
|
fileExt, contentType := metaStream.AudioFileMeta()
|
||||||
|
if fileExt != ".mp3" || contentType != "audio/mpeg" {
|
||||||
|
t.Fatalf("audio metadata mismatch: got (%q, %q)", fileExt, contentType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewOpenAITTSProvider_UsesConfiguredModel(t *testing.T) {
|
func TestNewOpenAITTSProvider_UsesConfiguredModel(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -144,6 +202,27 @@ func TestNewOpenAITTSProvider_UsesConfiguredModel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewOpenAITTSProvider_UsesConfiguredVoiceAndResponseFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
provider := NewOpenAITTSProviderWithOptions(
|
||||||
|
"key",
|
||||||
|
"https://openrouter.ai/api/v1",
|
||||||
|
"",
|
||||||
|
"microsoft/mai-voice-2",
|
||||||
|
OpenAITTSOptions{
|
||||||
|
Voice: "en-US-Harper:MAI-Voice-2",
|
||||||
|
ResponseFormat: "mp3",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if provider.voice != "en-US-Harper:MAI-Voice-2" {
|
||||||
|
t.Fatalf("voice mismatch: got %q", provider.voice)
|
||||||
|
}
|
||||||
|
if provider.responseFormat != "mp3" {
|
||||||
|
t.Fatalf("responseFormat mismatch: got %q", provider.responseFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDetectTTS_UsesMimoProviderForMimoModels(t *testing.T) {
|
func TestDetectTTS_UsesMimoProviderForMimoModels(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -170,6 +249,36 @@ func TestDetectTTS_UsesMimoProviderForMimoModels(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDetectTTS_UsesOpenAIExtraBodyVoiceAndResponseFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
provider := DetectTTS(&config.Config{
|
||||||
|
Voice: config.VoiceConfig{TTSModelName: "mai-voice"},
|
||||||
|
ModelList: []*config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "mai-voice",
|
||||||
|
Model: "openrouter/microsoft/mai-voice-2",
|
||||||
|
APIKeys: config.SimpleSecureStrings("sk-openrouter"),
|
||||||
|
ExtraBody: map[string]any{
|
||||||
|
"voice": "en-US-Harper:MAI-Voice-2",
|
||||||
|
"response_format": "mp3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ttsProvider, ok := provider.(*OpenAITTSProvider)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("DetectTTS() type = %T, want *OpenAITTSProvider", provider)
|
||||||
|
}
|
||||||
|
if ttsProvider.voice != "en-US-Harper:MAI-Voice-2" {
|
||||||
|
t.Fatalf("voice mismatch: got %q", ttsProvider.voice)
|
||||||
|
}
|
||||||
|
if ttsProvider.responseFormat != "mp3" {
|
||||||
|
t.Fatalf("responseFormat mismatch: got %q", ttsProvider.responseFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type stubTTSProvider struct {
|
type stubTTSProvider struct {
|
||||||
name string
|
name string
|
||||||
}
|
}
|
||||||
@@ -245,3 +354,51 @@ func TestSynthesizeAndStore_UsesMp3MetadataForMimo(t *testing.T) {
|
|||||||
t.Fatalf("filename extension = %q, want %q", filepath.Ext(meta.Filename), ".mp3")
|
t.Fatalf("filename extension = %q, want %q", filepath.Ext(meta.Filename), ".mp3")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSynthesizeAndStore_UsesStreamProvidedAudioMetadata(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
bodyBytes, _ := io.ReadAll(r.Body)
|
||||||
|
_ = r.Body.Close()
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.Unmarshal(bodyBytes, &body)
|
||||||
|
if body["response_format"] == "opus" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"response_format is invalid"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("mp3-audio"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
provider := NewOpenAITTSProvider("k123", server.URL, "", "x-ai/grok-voice-tts-1.0")
|
||||||
|
ref, err := SynthesizeAndStore(
|
||||||
|
context.Background(),
|
||||||
|
provider,
|
||||||
|
store,
|
||||||
|
"hello",
|
||||||
|
"",
|
||||||
|
"telegram",
|
||||||
|
"chat123",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SynthesizeAndStore failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path, meta, err := store.ResolveWithMeta(ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveWithMeta failed: %v", err)
|
||||||
|
}
|
||||||
|
if meta.ContentType != "audio/mpeg" {
|
||||||
|
t.Fatalf("ContentType = %q, want %q", meta.ContentType, "audio/mpeg")
|
||||||
|
}
|
||||||
|
if filepath.Ext(path) != ".mp3" {
|
||||||
|
t.Fatalf("stored file extension = %q, want %q", filepath.Ext(path), ".mp3")
|
||||||
|
}
|
||||||
|
if filepath.Ext(meta.Filename) != ".mp3" {
|
||||||
|
t.Fatalf("filename extension = %q, want %q", filepath.Ext(meta.Filename), ".mp3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -468,7 +468,8 @@ Recommended setup:
|
|||||||
1. add a TTS-capable model entry to `model_list`
|
1. add a TTS-capable model entry to `model_list`
|
||||||
2. set `voice.tts_model_name` to that entry's `model_name`
|
2. set `voice.tts_model_name` to that entry's `model_name`
|
||||||
3. store the API key in `.security.yml`
|
3. store the API key in `.security.yml`
|
||||||
4. enable `send_tts` in the tool configuration if you want the agent to emit speech files
|
4. if the provider needs model-specific TTS fields, add them under `model_list[].extra_body`
|
||||||
|
5. enable `send_tts` in the tool configuration if you want the agent to emit speech files
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -493,6 +494,35 @@ model_list:
|
|||||||
- "sk-openai-your-key"
|
- "sk-openai-your-key"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Example with OpenRouter MAI Voice 2:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "mai-voice-2",
|
||||||
|
"provider": "openrouter",
|
||||||
|
"model": "microsoft/mai-voice-2",
|
||||||
|
"api_base": "https://openrouter.ai/api/v1",
|
||||||
|
"extra_body": {
|
||||||
|
"voice": "en-US-Harper:MAI-Voice-2",
|
||||||
|
"response_format": "mp3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"voice": {
|
||||||
|
"tts_model_name": "mai-voice-2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
mai-voice-2:
|
||||||
|
api_keys:
|
||||||
|
- "sk-or-your-openrouter-key"
|
||||||
|
```
|
||||||
|
|
||||||
### Current TTS Provider Paths
|
### Current TTS Provider Paths
|
||||||
|
|
||||||
| Provider path | Example model | Notes |
|
| Provider path | Example model | Notes |
|
||||||
@@ -505,6 +535,8 @@ Operational notes:
|
|||||||
- the preferred selection path is `voice.tts_model_name`
|
- the preferred selection path is `voice.tts_model_name`
|
||||||
- if that is missing, PicoClaw can still scan `model_list` for the first API-backed model whose ID contains `tts`
|
- if that is missing, PicoClaw can still scan `model_list` for the first API-backed model whose ID contains `tts`
|
||||||
- the current OpenAI-style TTS request defaults to `voice: alloy` and `response_format: opus`
|
- the current OpenAI-style TTS request defaults to `voice: alloy` and `response_format: opus`
|
||||||
|
- you can override `voice` and `response_format` for a specific TTS model through `model_list[].extra_body`
|
||||||
|
- if a provider rejects `response_format`, PicoClaw retries once without that field
|
||||||
- `send_tts` is only registered when TTS detection succeeds
|
- `send_tts` is only registered when TTS detection succeeds
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user