Files
media/service/audio/audio_extract_service.go
2026-05-20 11:32:39 +08:00

186 lines
4.9 KiB
Go
Raw Permalink 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"
"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
}