Some checks failed
Build and Deploy Backend / build-and-deploy (push) Failing after 4m28s
## 变更内容
### k8s/ingress.yaml
- 新增 /xiaozhi/v1/ 路径规则,将 WebSocket 流量路由到 hw-ws-svc:8888
- Traefik 最长前缀优先,/xiaozhi/v1/ 不影响 / 下的 Django 路由
### hw_service_go/k8s/service.yaml
- Service 类型由 LoadBalancer 改为 ClusterIP
- 移除阿里云 SLB 注解(通过 Traefik Ingress 统一暴露,不再需要独立公网 IP)
### hw_service_go/k8s/deployment.yaml
- 镜像地址改为 ${CI_REGISTRY_IMAGE}/hw-ws-service:latest 占位符
- CI/CD 部署时统一通过 sed 替换为华为云 SWR 实际地址
### hw_service_go/internal/server/server.go
- 新增 GET /xiaozhi/v1/healthz 接口,返回 {"status":"ok","active_connections":N}
- 用于部署后验证服务存活及当前连接数
### .gitea/workflows/deploy.yaml
- 新增 Build and Push HW WebSocket Service 步骤,构建并推送 hw_service_go 镜像
- 部署步骤新增 kubectl apply hw_service_go/k8s/deployment.yaml 和 service.yaml
- 新增 kubectl rollout restart deployment/hw-ws-service
### run.sh
- 本地同时启动 Django + hw_service_go 的开发脚本
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
128 lines
3.7 KiB
Go
128 lines
3.7 KiB
Go
package audio
|
||
|
||
import (
|
||
"context"
|
||
"encoding/binary"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os/exec"
|
||
"time"
|
||
|
||
"github.com/hraban/opus"
|
||
)
|
||
|
||
// MP3URLToOpusFrames 从 audioURL 下载音频,通过 ffmpeg pipe 解码为 PCM,
|
||
// 再用 libopus 编码为 60ms 帧列表,全程流式处理不落磁盘。
|
||
//
|
||
// ⚠️ 安全约束:audioURL 只能作为 http.Get 的参数,
|
||
// 绝对不能出现在 exec.Command 的参数列表中(防止命令注入)。
|
||
func MP3URLToOpusFrames(ctx context.Context, audioURL string) ([][]byte, error) {
|
||
// 1. 下载音频(流式,不全量载入内存)
|
||
httpCtx, httpCancel := context.WithTimeout(ctx, 60*time.Second)
|
||
defer httpCancel()
|
||
|
||
req, err := http.NewRequestWithContext(httpCtx, http.MethodGet, audioURL, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("audio: build request: %w", err)
|
||
}
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("audio: download: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("audio: download status %d", resp.StatusCode)
|
||
}
|
||
|
||
// 2. ffmpeg:stdin 读原始音频,stdout 输出 s16le PCM(16kHz 单声道)
|
||
// 所有参数硬编码,audioURL 不进入命令行(防命令注入)
|
||
ffmpegCtx, ffmpegCancel := context.WithTimeout(ctx, 120*time.Second)
|
||
defer ffmpegCancel()
|
||
|
||
cmd := exec.CommandContext(ffmpegCtx,
|
||
"ffmpeg",
|
||
"-nostdin",
|
||
"-loglevel", "error", // 只输出错误,不污染 stdout pipe
|
||
"-i", "pipe:0", // 从 stdin 读输入
|
||
"-ar", "16000", // 目标采样率
|
||
"-ac", "1", // 单声道
|
||
"-f", "s16le", // 输出格式:有符号 16bit 小端 PCM
|
||
"pipe:1", // 输出到 stdout
|
||
)
|
||
cmd.Stdin = resp.Body // HTTP body 直接接 ffmpeg stdin,不经过磁盘
|
||
|
||
pcmReader, err := cmd.StdoutPipe()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("audio: stdout pipe: %w", err)
|
||
}
|
||
stderrPipe, _ := cmd.StderrPipe()
|
||
|
||
if err := cmd.Start(); err != nil {
|
||
return nil, fmt.Errorf("audio: start ffmpeg: %w", err)
|
||
}
|
||
|
||
// 3. 逐帧读取 PCM 并实时 Opus 编码
|
||
enc, err := opus.NewEncoder(SampleRate, Channels, opus.AppAudio)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("audio: create encoder: %w", err)
|
||
}
|
||
|
||
pcmBuf := make([]int16, FrameSize) // 960 int16 samples
|
||
opusBuf := make([]byte, 4000) // Opus 输出缓冲(4KB 足够单帧)
|
||
var frames [][]byte
|
||
|
||
for {
|
||
err := binary.Read(pcmReader, binary.LittleEndian, pcmBuf)
|
||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||
// 最后一帧不足时已补零(binary.Read 会读已有字节),直接编码
|
||
if err == io.ErrUnexpectedEOF {
|
||
n, encErr := enc.Encode(pcmBuf, opusBuf)
|
||
if encErr == nil && n > 0 {
|
||
frame := make([]byte, n)
|
||
copy(frame, opusBuf[:n])
|
||
frames = append(frames, frame)
|
||
}
|
||
}
|
||
break
|
||
}
|
||
if err != nil {
|
||
// ffmpeg 已结束(context cancel 等),读取结束
|
||
break
|
||
}
|
||
|
||
n, err := enc.Encode(pcmBuf, opusBuf)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("audio: opus encode: %w", err)
|
||
}
|
||
frame := make([]byte, n)
|
||
copy(frame, opusBuf[:n])
|
||
frames = append(frames, frame)
|
||
}
|
||
|
||
// 排空 stderr 避免 ffmpeg 阻塞
|
||
io.Copy(io.Discard, stderrPipe)
|
||
|
||
if err := cmd.Wait(); err != nil {
|
||
// context 超时导致的退出不视为错误(已有 frames 可以播放)
|
||
if ffmpegCtx.Err() == nil {
|
||
return nil, fmt.Errorf("audio: ffmpeg exit: %w", err)
|
||
}
|
||
}
|
||
|
||
if len(frames) == 0 {
|
||
return nil, fmt.Errorf("audio: no frames produced from %s", truncateURL(audioURL))
|
||
}
|
||
|
||
return frames, nil
|
||
}
|
||
|
||
// truncateURL 截断 URL 用于日志,避免输出带签名的完整 URL。
|
||
func truncateURL(u string) string {
|
||
if len(u) > 80 {
|
||
return u[:80] + "..."
|
||
}
|
||
return u
|
||
}
|