feat: load configuration from dotenv

This commit is contained in:
2026-08-27 22:22:07 +08:00
parent 50bec4fdb6
commit 6772bf46be
6 changed files with 164 additions and 9 deletions
+54
View File
@@ -0,0 +1,54 @@
# 复制为 .env 后填写:cp .env.example .env
# 程序启动时自动加载当前目录的 .env;已存在的系统环境变量优先。
# ===== 阿里云百炼(必填) =====
# 获取 API Keyhttps://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
+2
View File
@@ -0,0 +1,2 @@
.env
voicetest
+32 -8
View File
@@ -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.5sqwen 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.5sqwen 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.5sqwen 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 模型及预置/复刻音色均显示为下拉框
+46
View File
@@ -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", ""),
+4 -1
View File
@@ -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)
+26
View File
@@ -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{})