package audio import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "github.com/gogf/gf/v2/frame/g" ) // AudioExtractService 音频提取服务 type audioExtractService struct{} // AudioExtract 音频提取服务单例 var AudioExtract = new(audioExtractService) // ExtractAudioReq 提取音频请求 type ExtractAudioReq struct { VideoPath string // 视频文件路径 Format string // 输出音频格式,默认 mp3 } // ExtractAudioRes 提取音频响应 type ExtractAudioRes struct { AudioPath string // 提取后的音频文件路径 Duration string // 音频时长 Size int64 // 音频文件大小(bytes) } // Extract 从视频中提取音频 func (s *audioExtractService) Extract(ctx context.Context, req *ExtractAudioReq) (res *ExtractAudioRes, err error) { // 1. 校验视频文件存在 if _, err = os.Stat(req.VideoPath); os.IsNotExist(err) { return nil, fmt.Errorf("视频文件不存在: %s", req.VideoPath) } // 2. 校验 ffmpeg 是否可用 ffmpegPath, err := s.getFFmpegPath() if err != nil { return nil, err } // 3. 确定输出格式 format := req.Format if format == "" { format = "mp3" } format = strings.TrimLeft(format, ".") // 4. 生成输出文件路径 outputDir := filepath.Dir(req.VideoPath) baseName := strings.TrimSuffix(filepath.Base(req.VideoPath), filepath.Ext(req.VideoPath)) timestamp := time.Now().UnixMilli() outputName := fmt.Sprintf("%s_audio_%d.%s", baseName, timestamp, format) outputPath := filepath.Join(outputDir, outputName) g.Log().Infof(ctx, "开始提取音频: video=%s, output=%s", req.VideoPath, outputPath) // 5. 构建 ffmpeg 命令 // 提取音频并转换为指定格式 args := []string{ "-i", req.VideoPath, "-vn", // 去掉视频流 "-acodec", "libmp3lame", // 使用 mp3 编码器(mp3格式) "-ab", "192k", // 音频比特率 "-ar", "44100", // 采样率 "-ac", "2", // 双声道 "-y", // 覆盖输出文件 outputPath, } // 如果输出不是 mp3,调整编码器 switch format { case "aac": args[4] = "aac" case "wav": args[4] = "pcm_s16le" args[5] = "-vn" args = args[:8] // wav 不需要指定比特率等参数 args = append(args, outputPath) case "ogg": args[4] = "libvorbis" case "flac": args[4] = "flac" } cmd := exec.CommandContext(ctx, ffmpegPath, args...) // 捕获输出用于调试 output, execErr := cmd.CombinedOutput() if execErr != nil { g.Log().Errorf(ctx, "ffmpeg 执行失败: %v, output: %s", execErr, string(output)) return nil, fmt.Errorf("音频提取失败: %v", execErr) } // 6. 验证输出文件 stat, statErr := os.Stat(outputPath) if statErr != nil { return nil, fmt.Errorf("音频文件生成失败: %v", statErr) } // 7. 获取音频时长(通过 ffprobe) duration, _ := s.getAudioDuration(ctx, ffmpegPath, outputPath) g.Log().Infof(ctx, "音频提取成功: path=%s, size=%d, duration=%s", outputPath, stat.Size(), duration) res = &ExtractAudioRes{ AudioPath: outputPath, Duration: duration, Size: stat.Size(), } return } // getFFmpegPath 获取 ffmpeg 可执行路径 func (s *audioExtractService) getFFmpegPath() (string, error) { // 1. 优先从配置读取 ffmpegPath := g.Cfg().MustGet(context.Background(), "ffmpeg.path", "").String() if ffmpegPath != "" { if _, err := os.Stat(ffmpegPath); err == nil { return ffmpegPath, nil } } // 2. 从 PATH 中查找 path, err := exec.LookPath("ffmpeg") if err != nil { return "", fmt.Errorf("未找到 ffmpeg,请确保已安装 ffmpeg 或在配置中指定路径") } return path, nil } // getAudioDuration 获取音频时长 func (s *audioExtractService) getAudioDuration(ctx context.Context, ffmpegPath string, audioPath string) (string, error) { // 使用 ffprobe 获取时长 // 先尝试查找 ffprobe ffprobePath := filepath.Join(filepath.Dir(ffmpegPath), "ffprobe") if _, err := os.Stat(ffprobePath); os.IsNotExist(err) { ffprobePath = "ffprobe" } cmd := exec.CommandContext(ctx, ffprobePath, "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", audioPath, ) output, err := cmd.Output() if err != nil { return "", err } durationStr := strings.TrimSpace(string(output)) // 转换为人类可读格式: 秒 -> HH:MM:SS var seconds float64 fmt.Sscanf(durationStr, "%f", &seconds) hours := int(seconds) / 3600 minutes := (int(seconds) % 3600) / 60 secs := int(seconds) % 60 return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, secs), nil } // ExtractAndCleanup 提取音频并清理临时视频文件 func (s *audioExtractService) ExtractAndCleanup(ctx context.Context, req *ExtractAudioReq) (res *ExtractAudioRes, err error) { res, err = s.Extract(ctx, req) if err != nil { return nil, err } // 尝试删除原始视频文件 if req.VideoPath != "" { if removeErr := os.Remove(req.VideoPath); removeErr != nil { g.Log().Warningf(ctx, "删除临时视频文件失败: %v", removeErr) } } return }