feat: load configuration from dotenv
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
voicetest
|
||||
@@ -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 模型及预置/复刻音色均显示为下拉框
|
||||
|
||||
@@ -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", ""),
|
||||
|
||||
@@ -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
@@ -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{})
|
||||
|
||||
Reference in New Issue
Block a user