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