mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 18:16:09 +00:00
Baseline groundwork for TTS expansion plan. Introduces a capabilities system that the follow-up plan (phase-01..04) builds on. Backend: - `audio.ParamSchema` / `ProviderCapabilities` types + per-provider capabilities.go for edge, elevenlabs, minimax, openai, gemini. - Gemini TTS provider (client, models, voices, wav encoder, multi-speaker via SpeakerVoice, audio-tag aware prompts). - TTSOptions gains `Params map[string]any` (read-only) + `Speakers`. - `VoiceListProvider` interface decouples HTTP voice handler from provider-specific impls. - `nested_keys.go` resolves dot-separated param paths for nested provider bodies (voice_settings.stability etc.). - Characterization + defaults-invariant tests per provider lock nil-params byte-equivalence before new params land. - `/v1/tts/capabilities` HTTP endpoint + integration coverage. - Dual-read tests (PG + SQLite) for tts_config. Frontend (web + desktop): - `DynamicParamForm` with depends-on evaluation, split into fields/logic modules. Slider primitive added. - `AudioTagPicker` + `MultiSpeakerEditor` for Gemini. - `voice-picker` refactored toward portal UX; combobox tightened. - tts-capabilities API client + typed hooks. - i18n catalogs (en/vi/zh) expanded; parity tests guard key drift. - TTS page reorganised (voice-model-section removed; playground + credentials + provider-setup split cleanly). Docs: codebase-summary, project-changelog, tts-provider-capabilities.
136 lines
3.9 KiB
Go
136 lines
3.9 KiB
Go
package openai_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/audio"
|
|
"github.com/nextlevelbuilder/goclaw/internal/audio/openai"
|
|
)
|
|
|
|
func TestSynthesize_AppliesParams_Speed(t *testing.T) {
|
|
cfg := openai.Config{APIKey: "k"}
|
|
body := captureOpenAIBody(t, cfg, audio.TTSOptions{
|
|
Params: map[string]any{"speed": 1.5},
|
|
})
|
|
var m map[string]any
|
|
json.Unmarshal(body, &m)
|
|
if v, _ := m["speed"].(float64); v != 1.5 {
|
|
t.Errorf("speed: got %v, want 1.5", m["speed"])
|
|
}
|
|
}
|
|
|
|
func TestSynthesize_AppliesParams_ResponseFormat(t *testing.T) {
|
|
cfg := openai.Config{APIKey: "k"}
|
|
body := captureOpenAIBody(t, cfg, audio.TTSOptions{
|
|
Params: map[string]any{"response_format": "opus"},
|
|
})
|
|
var m map[string]any
|
|
json.Unmarshal(body, &m)
|
|
if v, _ := m["response_format"].(string); v != "opus" {
|
|
t.Errorf("response_format: got %q, want opus", m["response_format"])
|
|
}
|
|
}
|
|
|
|
func TestSynthesize_AppliesParams_Instructions(t *testing.T) {
|
|
cfg := openai.Config{APIKey: "k"}
|
|
body := captureOpenAIBody(t, cfg, audio.TTSOptions{
|
|
Model: "gpt-4o-mini-tts",
|
|
Params: map[string]any{"instructions": "speak slowly"},
|
|
})
|
|
var m map[string]any
|
|
json.Unmarshal(body, &m)
|
|
if v, _ := m["instructions"].(string); v != "speak slowly" {
|
|
t.Errorf("instructions: got %q, want 'speak slowly'", m["instructions"])
|
|
}
|
|
}
|
|
|
|
func TestSynthesize_AppliesParams_OmitsEmpty_NilParams(t *testing.T) {
|
|
cfg := openai.Config{APIKey: "k"}
|
|
body := captureOpenAIBody(t, cfg, audio.TTSOptions{})
|
|
var m map[string]any
|
|
json.Unmarshal(body, &m)
|
|
// instructions must be absent when not set
|
|
if _, ok := m["instructions"]; ok {
|
|
t.Error("instructions must not appear in body when not in params")
|
|
}
|
|
}
|
|
|
|
func TestSynthesize_DoesNotMutateCallerParams(t *testing.T) {
|
|
cfg := openai.Config{APIKey: "k"}
|
|
original := map[string]any{
|
|
"speed": 1.0,
|
|
"sentinel": "untouched",
|
|
}
|
|
// Deep copy to compare after call.
|
|
snapshot := map[string]any{
|
|
"speed": original["speed"],
|
|
"sentinel": original["sentinel"],
|
|
}
|
|
|
|
captureOpenAIBody(t, cfg, audio.TTSOptions{Params: original})
|
|
|
|
for k, want := range snapshot {
|
|
if got := original[k]; got != want {
|
|
t.Errorf("caller Params mutated: key %q was %v, now %v", k, want, got)
|
|
}
|
|
}
|
|
if len(original) != len(snapshot) {
|
|
t.Errorf("caller Params size changed: was %d, now %d", len(snapshot), len(original))
|
|
}
|
|
}
|
|
|
|
func TestCapabilities_HasParam_Speed(t *testing.T) {
|
|
p := openai.NewProvider(openai.Config{APIKey: "k"})
|
|
caps := p.Capabilities()
|
|
assertParamExists(t, caps.Params, "speed", audio.ParamTypeRange)
|
|
}
|
|
|
|
func TestCapabilities_HasParam_ResponseFormat(t *testing.T) {
|
|
p := openai.NewProvider(openai.Config{APIKey: "k"})
|
|
caps := p.Capabilities()
|
|
assertParamExists(t, caps.Params, "response_format", audio.ParamTypeEnum)
|
|
}
|
|
|
|
func TestCapabilities_DependsOn_OpenAIInstructions(t *testing.T) {
|
|
p := openai.NewProvider(openai.Config{APIKey: "k"})
|
|
caps := p.Capabilities()
|
|
for _, param := range caps.Params {
|
|
if param.Key == "instructions" {
|
|
if len(param.DependsOn) == 0 {
|
|
t.Fatal("instructions param must have DependsOn")
|
|
}
|
|
dep := param.DependsOn[0]
|
|
if dep.Field != "model" {
|
|
t.Errorf("DependsOn.Field: got %q, want model", dep.Field)
|
|
}
|
|
if dep.Value != "gpt-4o-mini-tts" {
|
|
t.Errorf("DependsOn.Value: got %v, want gpt-4o-mini-tts", dep.Value)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Error("instructions param not found in Capabilities")
|
|
}
|
|
|
|
func TestCapabilities_Has13Voices(t *testing.T) {
|
|
p := openai.NewProvider(openai.Config{APIKey: "k"})
|
|
caps := p.Capabilities()
|
|
if len(caps.Voices) != 13 {
|
|
t.Errorf("expected 13 voices, got %d", len(caps.Voices))
|
|
}
|
|
}
|
|
|
|
func assertParamExists(t *testing.T, params []audio.ParamSchema, key string, typ audio.ParamType) {
|
|
t.Helper()
|
|
for _, p := range params {
|
|
if p.Key == key {
|
|
if p.Type != typ {
|
|
t.Errorf("param %q: type got %q, want %q", key, p.Type, typ)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Errorf("param %q not found in schema", key)
|
|
}
|