diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0d3bbcf --- /dev/null +++ b/.env.example @@ -0,0 +1,54 @@ +# 复制为 .env 后填写:cp .env.example .env +# 程序启动时自动加载当前目录的 .env;已存在的系统环境变量优先。 + +# ===== 阿里云百炼(必填) ===== +# 获取 API Key:https://help.aliyun.com/zh/model-studio/get-api-key +# Workspace ID / API Host:百炼控制台 → 业务空间管理 → 复制 API Host。 +# API Key、HTTP/WSS 地址必须属于同一地域和业务空间。 +BAILIAN_API_KEY=sk-your-api-key + +# OpenAI 兼容地址,不要追加 /chat/completions。 +# 北京:https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/compatible-mode/v1 +# 新加坡:https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1 +BAILIAN_BASE_URL=https://your-workspace-id.cn-beijing.maas.aliyuncs.com/compatible-mode/v1 + +# 实时 ASR/TTS WebSocket 地址。 +# 文档:https://help.aliyun.com/zh/model-studio/fun-asr-realtime-websocket-api +# 北京:wss://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference +# 新加坡:wss://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/inference +BAILIAN_WSS_BASE_URL=wss://your-workspace-id.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference + +# ===== LLM / TTS ===== +# 可用模型:https://help.aliyun.com/zh/model-studio/getting-started/models +BAILIAN_LLM_MODEL=qwen3.8-flash +BAILIAN_TTS_MODEL=cosyvoice-v3.5-plus + +# 复刻/设计音色返回的 voice_id;也可启动后在页面下拉选择。 +# 创建与查询:https://help.aliyun.com/zh/model-studio/cosyvoice-clone-design-api +# 注意:创建音色和语音合成必须使用相同的 TTS 模型。 +BAILIAN_TTS_VOICE= +BAILIAN_TTS_INSTRUCTION= + +# 声音复刻管理接口,用于加载当前账号的复刻/设计音色列表。 +# 北京:https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/audio/tts/customization +# 新加坡:https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api/v1/services/audio/tts/customization +BAILIAN_VOICE_API_URL=https://your-workspace-id.cn-beijing.maas.aliyuncs.com/api/v1/services/audio/tts/customization + +# ===== 火山引擎豆包流式 ASR(可选) ===== +# 开通服务并获取凭证:火山引擎控制台 → 语音技术 → 豆包流式语音识别。 +# 文档:https://www.volcengine.com/docs/6561/1354869 +# 新版控制台只填写 APP Key;旧版控制台留空此项并填写下方 APP ID + Access Token。 +VOLC_ASR_APP_KEY= + +# 旧版控制台凭证(二选一鉴权方式,不要与新版 APP Key 混填)。 +VOLC_ASR_APP_ID= +VOLC_ASR_ACCESS_TOKEN= + +# 1.0 小时版:volc.bigasr.sauc.duration +# 1.0 并发版:volc.bigasr.sauc.concurrent +# 2.0 小时版:volc.seedasr.sauc.duration +# 2.0 并发版:volc.seedasr.sauc.concurrent +VOLC_ASR_RESOURCE_ID=volc.bigasr.sauc.duration + +# ===== 服务 ===== +PORT=:8090 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6da84f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +voicetest diff --git a/README.md b/README.md index e8c8086..6d9d96b 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,33 @@ Chrome 麦克风 → 实时 ASR(可选多模型)→ LLM 客服人设回答 ## 运行 ```bash -source ~/.zshenv # BAILIAN_* 环境变量 +cp .env.example .env # 按注释填写百炼/火山凭证及地域地址 go build -o voicetest . && ./voicetest -# 打开 http://localhost:8090 (Chrome,需允许麦克风;仅 localhost/https 可用 getUserMedia) +# 程序启动时自动加载当前目录的 .env;系统环境变量优先 ``` +`.env.example` 已注明 API Key、Workspace ID、复刻音色及火山语音凭证的控制台获取入口;`.env` 已加入 `.gitignore`,不要提交真实密钥。 + +## Chrome 在 HTTP 地址授权麦克风 + +`http://localhost:8090` 默认可以使用麦克风。通过局域网 IP 或域名访问 HTTP 服务时,Chrome 默认禁止 `getUserMedia`,开发测试可这样放行: + +1. 打开 `chrome://flags/#unsafely-treat-insecure-origin-as-secure`。 +2. 将 **Insecure origins treated as secure** 设置为 **Enabled**。 +3. 填入完整来源,例如 `http://192.168.1.10:8090`,不能省略协议或端口。 +4. 点击 **Relaunch** 重启 Chrome。 +5. 重新打开页面,点击地址栏左侧站点图标 → **网站设置** → **麦克风** → **允许**。 + +也可使用独立测试配置启动 Chrome: + +```bash +google-chrome \ + --user-data-dir=/tmp/voice-test-chrome \ + --unsafely-treat-insecure-origin-as-secure=http://192.168.1.10:8090 +``` + +该选项仅用于受信任的开发网络;正式部署应使用 HTTPS。也可以通过 `ssh -L 8090:localhost:8090 user@server` 转发后访问 `http://localhost:8090`,无需放宽 Chrome 安全策略。 + ## 可选 ASR 模型 | 模型 | 厂商 | 已验证 | @@ -34,12 +56,14 @@ export VOLC_ASR_RESOURCE_ID=volc.bigasr.sauc.duration # 2.0 用 volc.seedasr.sa ## 环境变量 +完整模板与参数获取方式见 [`.env.example`](.env.example)。 + | 变量 | 默认 | 说明 | |---|---|---| -| `BAILIAN_BASE_URL` / `BAILIAN_API_KEY` / `BAILIAN_WSS_BASE_URL` | - | 阿里百炼(zshenv 已有) | +| `BAILIAN_BASE_URL` / `BAILIAN_API_KEY` / `BAILIAN_WSS_BASE_URL` | - | 阿里百炼地址与 API Key | | `BAILIAN_LLM_MODEL` | `qwen3.8-flash` | 客服回答模型(OpenAI 兼容流式) | | `BAILIAN_TTS_MODEL` | `cosyvoice-v3.5-plus` | 语音合成模型 | -| `BAILIAN_TTS_VOICE` | zshenv 音色 | 默认音色或百炼复刻/设计生成的 `voice_id` | +| `BAILIAN_TTS_VOICE` | 空 | 默认音色或百炼复刻/设计生成的 `voice_id` | | `BAILIAN_TTS_INSTRUCTION` | 空 | 默认声音表达指令,网页可覆盖 | | `BAILIAN_VOICE_API_URL` | DashScope 声音管理接口 | 查询当前账号的复刻/设计音色下拉列表 | | `PORT` | `:8090` | 监听地址 | @@ -72,11 +96,12 @@ ASR 尾静音实测:语音结束→final 判定 ≈ 0.5s(qwen 0.6s),`max - `llm.go` — OpenAI 兼容流式对话 + 客服 system prompt - `tts.go` — CosyVoice 流式合成(PCM16/16k) - `voices.go` — TTS 模型、预置音色与百炼复刻音色列表 +- `.env.example` — 环境变量模板及各参数获取方式 - `web/index.html` — 测试页(AudioWorklet 采集 PCM16/16k、WAV 样本录制、音量条、双栏转录) ## 已知简化 -- 无打断(barge-in):TTS 播报期间 ASR 仍在识别,建议戴耳机测试避免回声。 +- 打断依赖 ASR 产生 partial 事件;外放回声可能误触发,建议戴耳机测试。 - 会话历史仅存内存、每连接独立;刷新页面即重置。 ## 验证清单 @@ -86,7 +111,7 @@ ASR 尾静音实测:语音结束→final 判定 ≈ 0.5s(qwen 0.6s),`max - [x] 百炼 ASR 三模型协议全通:run-task → task-started → 音频流 → finish-task → task-finished - [x] 识别正确性:同一句“你好,我想查询一下我的订单什么时候发货。”三模型 100% 准确 - [x] LLM `qwen3.8-flash` 流式回答,客服人设生效(口语化、无 markdown、主动要订单号) -- [x] TTS `cosyvoice-v3.5-plus` + zshenv 复刻音色:PCM16/16k 音频正常返回(cosyvoice-v2 会报 418,勿用) +- [x] TTS `cosyvoice-v3.5-plus` + 百炼复刻音色:PCM16/16k 音频正常返回(cosyvoice-v2 会报 418,勿用) - [x] E2E 指标见上表;多轮对话 finals=2 replies=2 无错误,ASR 上下文注入生效 - [x] `fun-asr-flash` 不支持 continue-task → 已加模型守卫自动跳过 - [x] 火山 sauc 二进制帧协议:新版 X-Api-Key 鉴权、句级 utterance definite 判定 final、负包(flags=2/3)结束,真实凭证 E2E 验证通过(含多轮) @@ -94,8 +119,7 @@ ASR 尾静音实测:语音结束→final 判定 ≈ 0.5s(qwen 0.6s),`max ### 👤 需人工在 Chrome 验证 ```bash -source ~/.zshenv -cd ~/Workspace/voice_test && go build -o voicetest . && ./voicetest +go build -o voicetest . && ./voicetest ``` 1. [ ] Chrome 打开 `http://localhost:8090`,ASR/TTS 模型及预置/复刻音色均显示为下拉框 diff --git a/config.go b/config.go index 486e9da..6a16faf 100644 --- a/config.go +++ b/config.go @@ -1,9 +1,12 @@ package main import ( + "bufio" "crypto/rand" "fmt" "os" + "strconv" + "strings" ) type agentCfg struct { @@ -21,6 +24,49 @@ type agentCfg struct { VolcResourceID string } +// loadDotEnv 加载简单的 KEY=VALUE / export KEY=VALUE;系统环境变量优先。 +func loadDotEnv(path string) error { + f, err := os.Open(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for lineNo := 1; scanner.Scan(); lineNo++ { + line := strings.TrimSpace(strings.TrimPrefix(scanner.Text(), "\ufeff")) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) + key, value, ok := strings.Cut(line, "=") + key, value = strings.TrimSpace(key), strings.TrimSpace(value) + if !ok || key == "" { + return fmt.Errorf("%s:%d: 无效的 dotenv 配置", path, lineNo) + } + if len(value) >= 2 && value[0] == value[len(value)-1] { + switch value[0] { + case '\'': + value = value[1 : len(value)-1] + case '"': + value, err = strconv.Unquote(value) + if err != nil { + return fmt.Errorf("%s:%d: %w", path, lineNo, err) + } + } + } + if _, exists := os.LookupEnv(key); !exists { + if err := os.Setenv(key, value); err != nil { + return fmt.Errorf("%s:%d: %w", path, lineNo, err) + } + } + } + return scanner.Err() +} + func loadCfg() agentCfg { return agentCfg{ BailianBaseURL: getenv("BAILIAN_BASE_URL", ""), diff --git a/main.go b/main.go index 80ad87e..61c8c31 100644 --- a/main.go +++ b/main.go @@ -67,9 +67,12 @@ func (s *session) sendAudio(b []byte) { } func main() { + if err := loadDotEnv(".env"); err != nil { + log.Fatalf("加载 .env 失败: %v", err) + } cfg := loadCfg() if cfg.BailianKey == "" { - log.Fatal("缺少 BAILIAN_API_KEY 等环境变量(先 source ~/.zshenv)") + log.Fatal("缺少 BAILIAN_API_KEY(请配置 .env 或系统环境变量)") } log.Printf("LLM=%s TTS=%s voice=%s", cfg.LLMModel, cfg.TTSModel, cfg.TTSVoice) diff --git a/tts_test.go b/tts_test.go index ea6f95d..1a0f507 100644 --- a/tts_test.go +++ b/tts_test.go @@ -4,6 +4,8 @@ import ( "context" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" ) @@ -21,6 +23,30 @@ func TestNormalizeTTS(t *testing.T) { } } +func TestLoadDotEnv(t *testing.T) { + const loaded = "VOICE_TEST_DOTENV_LOADED" + old, existed := os.LookupEnv(loaded) + os.Unsetenv(loaded) + defer func() { + if existed { + os.Setenv(loaded, old) + } else { + os.Unsetenv(loaded) + } + }() + t.Setenv("VOICE_TEST_DOTENV_EXISTING", "system") + path := filepath.Join(t.TempDir(), ".env") + if err := os.WriteFile(path, []byte("export "+loaded+"=\"hello world\"\nVOICE_TEST_DOTENV_EXISTING=file\n"), 0600); err != nil { + t.Fatal(err) + } + if err := loadDotEnv(path); err != nil { + t.Fatal(err) + } + if os.Getenv(loaded) != "hello world" || os.Getenv("VOICE_TEST_DOTENV_EXISTING") != "system" { + t.Fatal("dotenv loading or environment precedence failed") + } +} + func TestASRCloseLeavesEventsForReader(t *testing.T) { t.Run("bailian", func(t *testing.T) { a := newBailianASR("model", agentCfg{})