215 lines
4.8 KiB
Go
215 lines
4.8 KiB
Go
package backend
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type AnalysisResult struct {
|
|
FilePath string `json:"file_path"`
|
|
FileSize int64 `json:"file_size"`
|
|
SampleRate uint32 `json:"sample_rate"`
|
|
Channels uint8 `json:"channels"`
|
|
BitsPerSample uint8 `json:"bits_per_sample"`
|
|
TotalSamples uint64 `json:"total_samples"`
|
|
Duration float64 `json:"duration"`
|
|
Bitrate int `json:"bit_rate"`
|
|
BitDepth string `json:"bit_depth"`
|
|
DynamicRange float64 `json:"dynamic_range"`
|
|
PeakAmplitude float64 `json:"peak_amplitude"`
|
|
RMSLevel float64 `json:"rms_level"`
|
|
}
|
|
|
|
type AnalysisDecodeResponse struct {
|
|
PCMBase64 string `json:"pcm_base64"`
|
|
SampleRate uint32 `json:"sample_rate"`
|
|
Channels uint8 `json:"channels"`
|
|
BitsPerSample uint8 `json:"bits_per_sample"`
|
|
Duration float64 `json:"duration"`
|
|
BitrateKbps int `json:"bitrate_kbps,omitempty"`
|
|
BitDepth string `json:"bit_depth,omitempty"`
|
|
}
|
|
|
|
func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
|
|
if !fileExists(filepath) {
|
|
return nil, fmt.Errorf("file does not exist: %s", filepath)
|
|
}
|
|
|
|
return GetMetadataWithFFprobe(filepath)
|
|
}
|
|
|
|
func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) {
|
|
ffprobePath, err := GetFFprobePath()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for i := 0; i < 5; i++ {
|
|
if f, err := os.Open(filePath); err == nil {
|
|
f.Close()
|
|
break
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
|
|
args := []string{
|
|
"-v", "error",
|
|
"-select_streams", "a:0",
|
|
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
|
|
"-of", "default=noprint_wrappers=0",
|
|
filePath,
|
|
}
|
|
cmd := exec.Command(ffprobePath, args...)
|
|
setHideWindow(cmd)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ffprobe failed: %v - %s", err, string(output))
|
|
}
|
|
|
|
infoMap := make(map[string]string)
|
|
lines := strings.Split(string(output), "\n")
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "=") {
|
|
parts := strings.SplitN(line, "=", 2)
|
|
infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
|
}
|
|
}
|
|
|
|
res := &AnalysisResult{
|
|
FilePath: filePath,
|
|
}
|
|
|
|
if info, err := os.Stat(filePath); err == nil {
|
|
res.FileSize = info.Size()
|
|
}
|
|
|
|
if val, ok := infoMap["sample_rate"]; ok {
|
|
s, _ := strconv.Atoi(val)
|
|
res.SampleRate = uint32(s)
|
|
}
|
|
if val, ok := infoMap["channels"]; ok {
|
|
c, _ := strconv.Atoi(val)
|
|
res.Channels = uint8(c)
|
|
}
|
|
if val, ok := infoMap["duration"]; ok {
|
|
d, _ := strconv.ParseFloat(val, 64)
|
|
res.Duration = d
|
|
}
|
|
if val, ok := infoMap["bit_rate"]; ok && val != "N/A" {
|
|
br, _ := strconv.Atoi(val)
|
|
res.Bitrate = br
|
|
}
|
|
|
|
bits := 0
|
|
if val, ok := infoMap["bits_per_raw_sample"]; ok && val != "N/A" {
|
|
bits, _ = strconv.Atoi(val)
|
|
}
|
|
if bits == 0 {
|
|
if val, ok := infoMap["bits_per_sample"]; ok && val != "N/A" {
|
|
bits, _ = strconv.Atoi(val)
|
|
}
|
|
}
|
|
|
|
res.BitsPerSample = uint8(bits)
|
|
if bits > 0 {
|
|
res.BitDepth = fmt.Sprintf("%d-bit", bits)
|
|
} else {
|
|
res.BitDepth = "Unknown"
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
func DecodeAudioForAnalysis(filePath string) (*AnalysisDecodeResponse, error) {
|
|
metadata, err := GetTrackMetadata(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pcmBase64, err := extractAnalysisPCMBase64(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp := &AnalysisDecodeResponse{
|
|
PCMBase64: pcmBase64,
|
|
SampleRate: metadata.SampleRate,
|
|
Channels: metadata.Channels,
|
|
BitsPerSample: metadata.BitsPerSample,
|
|
Duration: metadata.Duration,
|
|
BitDepth: metadata.BitDepth,
|
|
}
|
|
|
|
if metadata.Bitrate > 0 {
|
|
resp.BitrateKbps = metadata.Bitrate / 1000
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
func extractAnalysisPCMBase64(filePath string) (string, error) {
|
|
ffmpegPath, err := GetFFmpegPath()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
argSets := [][]string{
|
|
{
|
|
"-v", "error",
|
|
"-i", filePath,
|
|
"-vn",
|
|
"-map", "0:a:0",
|
|
"-af", "pan=mono|c0=c0",
|
|
"-f", "s16le",
|
|
"-acodec", "pcm_s16le",
|
|
"pipe:1",
|
|
},
|
|
{
|
|
"-v", "error",
|
|
"-i", filePath,
|
|
"-vn",
|
|
"-map", "0:a:0",
|
|
"-ac", "1",
|
|
"-f", "s16le",
|
|
"-acodec", "pcm_s16le",
|
|
"pipe:1",
|
|
},
|
|
}
|
|
|
|
var lastErr error
|
|
|
|
for _, args := range argSets {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
cmd := exec.Command(ffmpegPath, args...)
|
|
setHideWindow(cmd)
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
lastErr = fmt.Errorf("ffmpeg analysis decode failed: %w - %s", err, strings.TrimSpace(stderr.String()))
|
|
continue
|
|
}
|
|
|
|
if stdout.Len() == 0 {
|
|
lastErr = fmt.Errorf("ffmpeg analysis decode returned empty PCM output")
|
|
continue
|
|
}
|
|
|
|
return base64.StdEncoding.EncodeToString(stdout.Bytes()), nil
|
|
}
|
|
|
|
if lastErr != nil {
|
|
return "", lastErr
|
|
}
|
|
|
|
return "", fmt.Errorf("ffmpeg analysis decode failed")
|
|
}
|