feat(voice): 阿里百炼 STT/TTS 主链路接通(base64 STT + cosyvoice TTS按voice_id + REST /audio/* + voice_input.deviceId)
- voice/dashscope.go 新建:DashscopeSTT(qwen-audio-3.0-asr-flash, base64 data URI→text) + DashscopeTTS(cosyvoice 按 voice_id, 取非流式 24h URL 下载字节)
- cloud.go newDashboardSTT/TTS 由空 endpoint stub 改为真实百炼 adapter
- chain.go gating 从 DashscopeAPIKey!="" 改为 voiceConfigured(cfg); silent 兜底保留
- config.VoiceConfig 扩展 DASHSCOPE_BASE_URL/VOICE_STT_*/VOICE_TTS_* 字段(必填 DASHSCOPE_API_KEY)
- gateway: POST /api/v1/audio/{transcribe,speech} (owner+审计+MaxBytesReader); voiceInputRequest 增 deviceId 并透传 runner.Run
- 实测: cosyvoice-v3.5-plus+longxiaochun 回 418(引擎拒绝), 改用 cosyvoice-v3-flash+longxiaochun_v3; TTS 你好我是昔涟->106124B wav -> STT 回 你好我是西联, 链返回真实字节/文本
- 单测: dashscope_test/config voice defaults/gateway audio_test; go build/vet/test 全绿
- 声音复刻(响昔涟音色)待公网参考音频; 不做 create_voice
This commit is contained in:
1 parent
d8044763ac
commit
73dabb9dde
13 files changed
+1070
-15
No files matched your search
@@ -80,8 +80,8 @@ func WithProviders(steps ...Provider) Option {
|
||||
// New assembles a chain from cfg using the fixed degradation order
|
||||
// cloud → local → edge-tts/espeak → silent.
|
||||
//
|
||||
// - cloud is included only when a cloud voice setting is present (today an
|
||||
// API key) so a mis-configured build degrades quietly.
|
||||
// - cloud is included only when a cloud voice credential or model is present
|
||||
// (voiceConfigured) so a mis-configured build degrades quietly.
|
||||
// - local is a pure adaptation point and is only included when supplied via
|
||||
// WithProviders; by default it is skipped.
|
||||
// - the edge-tts or espeak rung is included according to cfg.TTSProvider.
|
||||
@@ -98,7 +98,7 @@ func New(cfg config.VoiceConfig, opts ...Option) *Chain {
|
||||
}
|
||||
|
||||
steps := make([]Provider, 0, 5)
|
||||
if cfg.DashscopeAPIKey != "" {
|
||||
if voiceConfigured(cfg) {
|
||||
steps = append(steps, CloudProvider(newDashboardSTT(cfg), newDashboardTTS(cfg)))
|
||||
}
|
||||
switch cfg.TTSProvider {
|
||||
@@ -110,3 +110,12 @@ func New(cfg config.VoiceConfig, opts ...Option) *Chain {
|
||||
steps = append(steps, SilentProvider())
|
||||
return NewChain(steps...)
|
||||
}
|
||||
|
||||
// voiceConfigured reports whether any cloud voice credential or model is set, so
|
||||
// the cloud rung is only added when the runtime intends to use DashScope. Any
|
||||
// single present field (API key, base URL, or an STT/TTS model) makes the cloud
|
||||
// rung worth trying; a missing key then makes the adapter return ErrNotConfigured
|
||||
// and the chain degrades quietly.
|
||||
func voiceConfigured(cfg config.VoiceConfig) bool {
|
||||
return cfg.DashscopeAPIKey != "" || cfg.DashscopeBaseURL != "" || cfg.STTModel != "" || cfg.TTSModel != ""
|
||||
}
|
||||
@@ -201,16 +201,15 @@ func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// newDashboardSTT returns the cloud STT for a config. The endpoint is an
|
||||
// adaptation point and is empty until config gains a voice URL; a call then
|
||||
// returns ErrNotConfigured and the chain degrades.
|
||||
// newDashboardSTT returns the cloud STT adapter for a DashScope config. A call
|
||||
// returns ErrNotConfigured when no API key is set so the chain degrades.
|
||||
func newDashboardSTT(cfg config.VoiceConfig) STT {
|
||||
return NewHTTPSTT("", cfg.DashscopeAPIKey)
|
||||
return NewDashscopeSTT(cfg)
|
||||
}
|
||||
|
||||
// newDashboardTTS returns the cloud TTS for a config.
|
||||
// newDashboardTTS returns the cloud TTS adapter for a DashScope config.
|
||||
func newDashboardTTS(cfg config.VoiceConfig) TTS {
|
||||
return NewHTTPTTS("", cfg.DashscopeAPIKey)
|
||||
return NewDashscopeTTS(cfg)
|
||||
}
|
||||
|
||||
// newEdgeTTS returns the edge-tts adapter point (not yet implemented).
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
package voice
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// DashScope (Alibaba Bailian) STT/TTS adapters for the cloud rung of the voice
|
||||
// chain. Requests follow the DashScope public API:
|
||||
//
|
||||
// - STT: POST {base}/services/aigc/multimodal-generation/generation
|
||||
// (qwen-audio-3.0-asr-flash, base64 input audio)
|
||||
// - TTS: POST {base}/services/audio/tts/SpeechSynthesizer
|
||||
// (cosyvoice-v3-flash, synthesized by voice_id)
|
||||
//
|
||||
// Both take a non-streaming response here: STT returns the transcript in the
|
||||
// output envelope; TTS (no X-DashScope-SSE header) returns a 24h audio URL that
|
||||
// we download so the caller always gets the actual bytes. Audio format ↔ mime
|
||||
// is mapped in both directions (mimeOf / the data: URI in the STT request).
|
||||
|
||||
const (
|
||||
// defaultSTTSampleRate is the input audio sample rate the ASR expects.
|
||||
defaultSTTSampleRate = 16000
|
||||
// dashscopeSTTPath is the multimodal-generation service path (ASR).
|
||||
dashscopeSTTPath = "/services/aigc/multimodal-generation/generation"
|
||||
// dashscopeTTSPath is the SpeechSynthesizer service path (TTS).
|
||||
dashscopeTTSPath = "/services/audio/tts/SpeechSynthesizer"
|
||||
// defaultDashscopeBaseURL is the Bailian API root when none is configured.
|
||||
defaultDashscopeBaseURL = "https://dashscope.aliyuncs.com/api/v1"
|
||||
)
|
||||
|
||||
// DashscopeSTT transcribes audio via DashScope multimodal-generation.
|
||||
type DashscopeSTT struct {
|
||||
endpoint string
|
||||
apiKey string
|
||||
model string
|
||||
format string
|
||||
language string
|
||||
sampleRate int
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewDashscopeSTT builds the DashScope STT adapter from cfg. Missing model /
|
||||
// language / base URL fall back to the DashScope defaults.
|
||||
func NewDashscopeSTT(cfg config.VoiceConfig) *DashscopeSTT {
|
||||
model := firstNonEmpty(cfg.STTModel, "qwen-audio-3.0-asr-flash")
|
||||
language := firstNonEmpty(cfg.STTLanguage, "zh")
|
||||
return &DashscopeSTT{
|
||||
endpoint: dashscopeEndpoint(cfg.DashscopeBaseURL) + dashscopeSTTPath,
|
||||
apiKey: cfg.DashscopeAPIKey,
|
||||
model: model,
|
||||
format: "wav",
|
||||
language: language,
|
||||
sampleRate: defaultSTTSampleRate,
|
||||
client: defaultHTTPClient(),
|
||||
}
|
||||
}
|
||||
|
||||
// STT posts the audio as a base64 data: URI and returns the recognized text.
|
||||
func (s *DashscopeSTT) STT(ctx context.Context, audio Audio) (string, error) {
|
||||
if s.apiKey == "" {
|
||||
return "", ErrNotConfigured
|
||||
}
|
||||
if len(audio.Data) == 0 {
|
||||
return "", fmt.Errorf("voice: %w: empty audio", ErrProviderFailed)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
format := audio.Format
|
||||
if format == "" {
|
||||
format = "wav"
|
||||
}
|
||||
payload, err := json.Marshal(dashscopeSTTRequest{
|
||||
Model: s.model,
|
||||
Input: dashscopeSTTInput{
|
||||
Messages: []dashscopeSTTMessage{{
|
||||
Role: "user",
|
||||
Content: []dashscopeSTTContent{{
|
||||
Type: "input_audio",
|
||||
InputAudio: dashscopeInputAudio{
|
||||
Data: "data:" + mimeOf(format) + ";base64," + base64.StdEncoding.EncodeToString(audio.Data),
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
Parameters: dashscopeSTTParameters{
|
||||
Format: format,
|
||||
SampleRate: s.sampleRate,
|
||||
LanguageHints: []string{s.language},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("voice: %w: marshal: %v", ErrProviderFailed, err)
|
||||
}
|
||||
body, err := dashscopePost(ctx, s.client, s.apiKey, s.endpoint, payload, "disable")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
text, err := extractSTTText(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("voice: %w: decode: %v", ErrProviderFailed, err)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// DashscopeTTS synthesizes speech video via DashScope SpeechSynthesizer.
|
||||
type DashscopeTTS struct {
|
||||
endpoint string
|
||||
apiKey string
|
||||
model string
|
||||
voiceID string
|
||||
format string
|
||||
sampleRate int
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewDashscopeTTS builds the DashScope TTS adapter from cfg. Missing model /
|
||||
// voice / format / base URL fall back to the DashScope defaults.
|
||||
func NewDashscopeTTS(cfg config.VoiceConfig) *DashscopeTTS {
|
||||
model := firstNonEmpty(cfg.TTSModel, "cosyvoice-v3-flash")
|
||||
voiceID := firstNonEmpty(cfg.TTSVoiceID, "longxiaochun_v3")
|
||||
format := firstNonEmpty(cfg.TTSFormat, "wav")
|
||||
sampleRate := cfg.TTSSampleRate
|
||||
if sampleRate == 0 {
|
||||
sampleRate = 24000
|
||||
}
|
||||
return &DashscopeTTS{
|
||||
endpoint: dashscopeEndpoint(cfg.DashscopeBaseURL) + dashscopeTTSPath,
|
||||
apiKey: cfg.DashscopeAPIKey,
|
||||
model: model,
|
||||
voiceID: voiceID,
|
||||
format: format,
|
||||
sampleRate: sampleRate,
|
||||
client: defaultHTTPClient(),
|
||||
}
|
||||
}
|
||||
|
||||
// TTS synthesizes audio for text. The non-streaming DashScope response carries a
|
||||
// 24h audio URL (or a base64 blob); we resolve it to bytes and return them so the
|
||||
// caller holds a complete audio file.
|
||||
func (t *DashscopeTTS) TTS(ctx context.Context, text string) (Audio, error) {
|
||||
if t.apiKey == "" {
|
||||
return Audio{}, ErrNotConfigured
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return Audio{}, ErrEmptyText
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Audio{}, err
|
||||
}
|
||||
payload, err := json.Marshal(dashscopeTTSRequest{
|
||||
Model: t.model,
|
||||
Input: dashscopeTTSInput{
|
||||
Text: text,
|
||||
Voice: t.voiceID,
|
||||
Format: t.format,
|
||||
SampleRate: t.sampleRate,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return Audio{}, fmt.Errorf("voice: %w: marshal: %v", ErrProviderFailed, err)
|
||||
}
|
||||
// No X-DashScope-SSE header → non-streaming URL response.
|
||||
body, err := dashscopePost(ctx, t.client, t.apiKey, t.endpoint, payload, "")
|
||||
if err != nil {
|
||||
return Audio{}, err
|
||||
}
|
||||
data, err := t.audioBytes(ctx, body)
|
||||
if err != nil {
|
||||
return Audio{}, err
|
||||
}
|
||||
return Audio{Format: t.format, Data: data}, nil
|
||||
}
|
||||
|
||||
// audioBytes resolves the synthesized audio from the DashScope response: a 24h
|
||||
// URL is downloaded, or an inline base64 blob is decoded.
|
||||
func (t *DashscopeTTS) audioBytes(ctx context.Context, body []byte) ([]byte, error) {
|
||||
var r dashscopeTTSResponse
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
return nil, fmt.Errorf("voice: %w: decode: %v", ErrProviderFailed, err)
|
||||
}
|
||||
if url := r.Output.Audio.URL; url != "" {
|
||||
data, err := t.download(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
if b64 := r.Output.Audio.Data; b64 != "" {
|
||||
data, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("voice: %w: decode audio: %v", ErrProviderFailed, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
return nil, fmt.Errorf("voice: %w: no audio in response", ErrProviderFailed)
|
||||
}
|
||||
|
||||
// download fetches an audio URL into memory, capping the size so an oversized
|
||||
// media response cannot exhaust memory.
|
||||
func (t *DashscopeTTS) download(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("voice: %w: build: %v", ErrProviderFailed, err)
|
||||
}
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("voice: %w: download: %v", ErrProviderFailed, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("voice: %w: download status %d", ErrProviderFailed, resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, cloudMaxResponseBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("voice: %w: download read: %v", ErrProviderFailed, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// dashscopeEndpoint returns the DashScope API base with a trailing slash trimmed,
|
||||
// defaulting to the public Bailian root when none is configured.
|
||||
func dashscopeEndpoint(baseURL string) string {
|
||||
base := strings.TrimRight(baseURL, "/")
|
||||
if base == "" {
|
||||
return defaultDashscopeBaseURL
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// firstNonEmpty returns a when non-empty, else b.
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// dashscopePost POSTs a JSON payload with the DashScope bearer auth and an
|
||||
// optional X-DashScope-SSE header, returning the decoded response body. A non-200
|
||||
// status is wrapped with the DashScope error body.
|
||||
func dashscopePost(ctx context.Context, client *http.Client, apiKey, url string, payload []byte, sse string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("voice: %w: build: %v", ErrProviderFailed, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
if sse != "" {
|
||||
req.Header.Set("X-DashScope-SSE", sse)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("voice: %w: request: %v", ErrProviderFailed, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, cloudMaxResponseBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("voice: %w: read: %v", ErrProviderFailed, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("voice: %w: dashscope status %d: %s", ErrProviderFailed, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// extractSTTText pulls the transcript out of a DashScope ASR response. The ASR
|
||||
// output shape varies by model, so several candidate paths are tried.
|
||||
func extractSTTText(body []byte) (string, error) {
|
||||
var r dashscopeSTTResponse
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if s := r.Output.Output.Sentence.Text; s != "" {
|
||||
return s, nil
|
||||
}
|
||||
if s := r.Output.Output.Text; s != "" {
|
||||
return s, nil
|
||||
}
|
||||
if s := r.Output.Text; s != "" {
|
||||
return s, nil
|
||||
}
|
||||
for _, ch := range r.Output.Choices {
|
||||
if len(ch.Message.Content) > 0 {
|
||||
if s := ch.Message.Content[0].Text; s != "" {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return r.Text, nil
|
||||
}
|
||||
|
||||
// mimeOf maps an audio format to its MIME type for the data: URI / Content-Type.
|
||||
func mimeOf(format string) string {
|
||||
switch strings.ToLower(format) {
|
||||
case "mp3", "mpeg":
|
||||
return "audio/mpeg"
|
||||
case "ogg", "oga":
|
||||
return "audio/ogg"
|
||||
case "webm":
|
||||
return "audio/webm"
|
||||
default:
|
||||
return "audio/wav"
|
||||
}
|
||||
}
|
||||
|
||||
// dashscopeSTTResponse is the DashScope ASR response envelope.
|
||||
type dashscopeSTTResponse struct {
|
||||
Output struct {
|
||||
Output struct {
|
||||
Sentence struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"sentence"`
|
||||
Text string `json:"text"`
|
||||
} `json:"output"`
|
||||
Text string `json:"text"`
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
} `json:"output"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// dashscopeTTSResponse is the DashScope TTS response envelope.
|
||||
type dashscopeTTSResponse struct {
|
||||
Output struct {
|
||||
Audio struct {
|
||||
URL string `json:"url"`
|
||||
Data string `json:"data"`
|
||||
} `json:"audio"`
|
||||
} `json:"output"`
|
||||
}
|
||||
|
||||
// dashscopeSTTRequest is the ASR request body.
|
||||
type dashscopeSTTRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input dashscopeSTTInput `json:"input"`
|
||||
Parameters dashscopeSTTParameters `json:"parameters"`
|
||||
}
|
||||
|
||||
// dashscopeSTTInput carries the multimodal messages list.
|
||||
type dashscopeSTTInput struct {
|
||||
Messages []dashscopeSTTMessage `json:"messages"`
|
||||
}
|
||||
|
||||
// dashscopeSTTMessage is one ASR user turn.
|
||||
type dashscopeSTTMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content []dashscopeSTTContent `json:"content"`
|
||||
}
|
||||
|
||||
// dashscopeSTTContent is one audio content block.
|
||||
type dashscopeSTTContent struct {
|
||||
Type string `json:"type"`
|
||||
InputAudio dashscopeInputAudio `json:"input_audio"`
|
||||
}
|
||||
|
||||
// dashscopeInputAudio carries the base64 data: URI audio.
|
||||
type dashscopeInputAudio struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// dashscopeSTTParameters tunes the ASR request.
|
||||
type dashscopeSTTParameters struct {
|
||||
Format string `json:"format"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
LanguageHints []string `json:"language_hints,omitempty"`
|
||||
}
|
||||
|
||||
// dashscopeTTSRequest is the TTS request body.
|
||||
type dashscopeTTSRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input dashscopeTTSInput `json:"input"`
|
||||
}
|
||||
|
||||
// dashscopeTTSInput carries text + voice + output format.
|
||||
type dashscopeTTSInput struct {
|
||||
Text string `json:"text"`
|
||||
Voice string `json:"voice"`
|
||||
Format string `json:"format"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//go:build dashscope_live
|
||||
|
||||
package voice
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// TestDashscopeLive verifies the real Alibaba Bailian STT + TTS end to end. It is
|
||||
// excluded from the default suite (build tag dashscope_live) so `go test ./...`
|
||||
// never hits the paid API. Run it explicitly with the key sourced from .env:
|
||||
//
|
||||
// set -a; source .env; set +a
|
||||
// go test -tags dashscope_live -run TestDashscopeLive -v ./internal/runtime/multimodal/voice/
|
||||
func TestDashscopeLive(t *testing.T) {
|
||||
key := os.Getenv("DASHSCOPE_API_KEY")
|
||||
if key == "" {
|
||||
t.Skip("DASHSCOPE_API_KEY not set")
|
||||
}
|
||||
cfg := config.VoiceConfig{DashscopeAPIKey: key}
|
||||
|
||||
// TTS: synthesize speech for a short line.
|
||||
tts := NewDashscopeTTS(cfg)
|
||||
audio, err := tts.TTS(context.Background(), "你好,我是昔涟")
|
||||
if err != nil {
|
||||
t.Fatalf("TTS error: %v", err)
|
||||
}
|
||||
t.Logf("TTS: format=%q bytes=%d", audio.Format, len(audio.Data))
|
||||
if len(audio.Data) == 0 {
|
||||
t.Fatal("TTS returned empty audio")
|
||||
}
|
||||
|
||||
// STT closed loop: transcribe the synthesized speech back to text.
|
||||
stt := NewDashscopeSTT(cfg)
|
||||
text, err := stt.STT(context.Background(), Audio{Format: "wav", Data: audio.Data})
|
||||
if err != nil {
|
||||
t.Fatalf("STT error: %v", err)
|
||||
}
|
||||
t.Logf("STT (of TTS audio): text=%q", text)
|
||||
|
||||
// STT on a generated silence wav, to confirm the empty-audio path is accepted.
|
||||
silence := silentWAVBytes(1, 16000)
|
||||
sttText, sttErr := stt.STT(context.Background(), Audio{Format: "wav", Data: silence})
|
||||
if sttErr != nil {
|
||||
t.Logf("STT (silence): error=%v", sttErr)
|
||||
} else {
|
||||
t.Logf("STT (silence): text=%q", sttText)
|
||||
}
|
||||
|
||||
// Exercise the degrading chain (the same object the gateway wires), proving it
|
||||
// returns real bytes/text rather than degrading to the silent terminal rung.
|
||||
ch := New(cfg)
|
||||
chTTS, chTTSErr := ch.TTS(context.Background(), "你好,我是昔涟")
|
||||
if chTTSErr != nil {
|
||||
t.Fatalf("chain TTS error: %v", chTTSErr)
|
||||
}
|
||||
t.Logf("chain TTS: format=%q bytes=%d", chTTS.Format, len(chTTS.Data))
|
||||
if len(chTTS.Data) == 0 {
|
||||
t.Fatal("chain TTS degraded to empty (silent) audio")
|
||||
}
|
||||
chText, chSTTErr := ch.STT(context.Background(), Audio{Format: "wav", Data: chTTS.Data})
|
||||
if chSTTErr != nil {
|
||||
t.Fatalf("chain STT error: %v", chSTTErr)
|
||||
}
|
||||
t.Logf("chain STT: text=%q", chText)
|
||||
if chText == "" {
|
||||
t.Fatal("chain STT degraded to empty (silent) text")
|
||||
}
|
||||
}
|
||||
|
||||
// silentWAVBytes builds a mono 16-bit 16 kHz silence WAV of durS seconds.
|
||||
func silentWAVBytes(durS, sampleRate int) []byte {
|
||||
dataLen := durS * sampleRate * 2
|
||||
buf := &bytes.Buffer{}
|
||||
// RIFF header + fmt chunk + data chunk.
|
||||
buf.WriteString("RIFF")
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint32(36+dataLen))
|
||||
buf.WriteString("WAVEfmt ")
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint32(16))
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint16(1)) // PCM
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint16(1)) // mono
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint32(sampleRate))
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint32(sampleRate*2))
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint16(2)) // block align
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint16(16)) // bits per sample
|
||||
buf.WriteString("data")
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint32(dataLen))
|
||||
buf.Write(make([]byte, dataLen))
|
||||
return buf.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package voice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
func TestDashscopeSTT(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/generation") {
|
||||
t.Errorf("path = %q, want .../generation", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer sk" {
|
||||
t.Errorf("auth = %q, want Bearer sk", r.Header.Get("Authorization"))
|
||||
}
|
||||
if r.Header.Get("X-DashScope-SSE") != "disable" {
|
||||
t.Errorf("sse = %q, want disable", r.Header.Get("X-DashScope-SSE"))
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body["model"] != "qwen-audio-3.0-asr-flash" {
|
||||
t.Errorf("model = %v, want qwen-audio-3.0-asr-flash", body["model"])
|
||||
}
|
||||
input := body["input"].(map[string]any)
|
||||
msgs := input["messages"].([]any)
|
||||
content := msgs[0].(map[string]any)["content"].([]any)
|
||||
audio := content[0].(map[string]any)["input_audio"].(map[string]any)["data"].(string)
|
||||
if !strings.HasPrefix(audio, "data:audio/wav;base64,") {
|
||||
t.Errorf("data uri = %q, want data:audio/wav;base64,...", audio)
|
||||
}
|
||||
params := body["parameters"].(map[string]any)
|
||||
if params["format"] != "wav" {
|
||||
t.Errorf("format = %v, want wav", params["format"])
|
||||
}
|
||||
hints := params["language_hints"].([]any)
|
||||
if len(hints) != 1 || hints[0] != "zh" {
|
||||
t.Errorf("language_hints = %v, want [zh]", hints)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"output":{"output":{"sentence":{"text":"你好世界"}}}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
stt := NewDashscopeSTT(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"})
|
||||
text, err := stt.STT(context.Background(), Audio{Format: "wav", Data: []byte("rawaudio")})
|
||||
if err != nil {
|
||||
t.Fatalf("STT error: %v", err)
|
||||
}
|
||||
if text != "你好世界" {
|
||||
t.Errorf("text = %q, want 你好世界", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashscopeSTT_ChoicesText(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"output":{"choices":[{"message":{"content":[{"text":"hello"}]}}]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
stt := NewDashscopeSTT(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"})
|
||||
text, err := stt.STT(context.Background(), Audio{Format: "wav", Data: []byte("x")})
|
||||
if err != nil {
|
||||
t.Fatalf("STT error: %v", err)
|
||||
}
|
||||
if text != "hello" {
|
||||
t.Errorf("text = %q, want hello", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashscopeSTT_NotConfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
stt := NewDashscopeSTT(config.VoiceConfig{})
|
||||
if _, err := stt.STT(context.Background(), Audio{Format: "wav", Data: []byte("x")}); err != ErrNotConfigured {
|
||||
t.Fatalf("err = %v, want ErrNotConfigured", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashscopeSTT_EmptyAudio(t *testing.T) {
|
||||
t.Parallel()
|
||||
stt := NewDashscopeSTT(config.VoiceConfig{DashscopeBaseURL: "http://x", DashscopeAPIKey: "sk"})
|
||||
if _, err := stt.STT(context.Background(), Audio{}); !errors.Is(err, ErrProviderFailed) {
|
||||
t.Fatalf("err = %v, want ErrProviderFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashscopeSTT_Non200(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"code":"InvalidParameter","message":"bad"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
stt := NewDashscopeSTT(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"})
|
||||
_, err := stt.STT(context.Background(), Audio{Format: "wav", Data: []byte("x")})
|
||||
if !errors.Is(err, ErrProviderFailed) {
|
||||
t.Fatalf("err = %v, want ErrProviderFailed", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "400") {
|
||||
t.Errorf("err = %v, want it to mention status 400", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashscopeTTS_URL(t *testing.T) {
|
||||
t.Parallel()
|
||||
audioBytes := []byte("RIFFxxxxWAVE")
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/SpeechSynthesizer"):
|
||||
if r.Header.Get("Authorization") != "Bearer sk" {
|
||||
t.Errorf("auth = %q, want Bearer sk", r.Header.Get("Authorization"))
|
||||
}
|
||||
if sse := r.Header.Get("X-DashScope-SSE"); sse != "" {
|
||||
t.Errorf("sse = %q, want empty (non-streaming URL)", sse)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body["model"] != "cosyvoice-v3-flash" {
|
||||
t.Errorf("model = %v, want cosyvoice-v3-flash", body["model"])
|
||||
}
|
||||
input := body["input"].(map[string]any)
|
||||
if input["text"] != "你好" {
|
||||
t.Errorf("text = %v, want 你好", input["text"])
|
||||
}
|
||||
if input["voice"] != "longxiaochun_v3" {
|
||||
t.Errorf("voice = %v, want longxiaochun_v3", input["voice"])
|
||||
}
|
||||
if input["format"] != "wav" {
|
||||
t.Errorf("format = %v, want wav", input["format"])
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"output":{"audio":{"url":"` + srv.URL + `/audio/out.wav"}}}`))
|
||||
case strings.HasSuffix(r.URL.Path, "/audio/out.wav"):
|
||||
w.Header().Set("Content-Type", "audio/wav")
|
||||
_, _ = w.Write(audioBytes)
|
||||
default:
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tts := NewDashscopeTTS(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"})
|
||||
audio, err := tts.TTS(context.Background(), "你好")
|
||||
if err != nil {
|
||||
t.Fatalf("TTS error: %v", err)
|
||||
}
|
||||
if audio.Format != "wav" {
|
||||
t.Errorf("format = %q, want wav", audio.Format)
|
||||
}
|
||||
if hex.EncodeToString(audio.Data) != hex.EncodeToString(audioBytes) {
|
||||
t.Errorf("data = %s, want %s", hex.EncodeToString(audio.Data), hex.EncodeToString(audioBytes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashscopeTTS_DataBlob(t *testing.T) {
|
||||
t.Parallel()
|
||||
audioBytes := []byte{9, 8, 7}
|
||||
b64 := base64.StdEncoding.EncodeToString(audioBytes)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"output":{"audio":{"data":"` + b64 + `"}}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tts := NewDashscopeTTS(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"})
|
||||
audio, err := tts.TTS(context.Background(), "hi")
|
||||
if err != nil {
|
||||
t.Fatalf("TTS error: %v", err)
|
||||
}
|
||||
if hex.EncodeToString(audio.Data) != hex.EncodeToString(audioBytes) {
|
||||
t.Errorf("data = %s, want %s", hex.EncodeToString(audio.Data), hex.EncodeToString(audioBytes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashscopeTTS_EmptyText(t *testing.T) {
|
||||
t.Parallel()
|
||||
tts := NewDashscopeTTS(config.VoiceConfig{DashscopeBaseURL: "http://x", DashscopeAPIKey: "sk"})
|
||||
if _, err := tts.TTS(context.Background(), " "); err != ErrEmptyText {
|
||||
t.Fatalf("err = %v, want ErrEmptyText", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashscopeTTS_NotConfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
tts := NewDashscopeTTS(config.VoiceConfig{})
|
||||
if _, err := tts.TTS(context.Background(), "hi"); err != ErrNotConfigured {
|
||||
t.Fatalf("err = %v, want ErrNotConfigured", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user