repair-agent c219ec2fcf
Some checks failed
Build and Deploy Backend / build-and-deploy (push) Failing after 4m28s
feat(hw-ws-service): 将 Go WebSocket 服务纳入 CI/CD 并通过 Traefik 统一入口
## 变更内容

### 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>
2026-03-02 17:16:26 +08:00

128 lines
3.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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. ffmpegstdin 读原始音频stdout 输出 s16le PCM16kHz 单声道)
// 所有参数硬编码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
}