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 }