Files
gwutilz/gwencoder/pkg/encoding/audio.go
2026-03-23 15:48:34 -07:00

324 lines
9.9 KiB
Go
Executable File

package encoding
import (
"fmt"
"os/exec"
"strconv"
"strings"
)
// AudioStandardizer contains audio standardization parameters
type AudioStandardizer struct {
Codec string // "aac" or "opus"
SkipIfCompatible bool // Default: true
BitratePerChannel int // Default: 80
StereoBitrate int // Default: 160
ChannelMode string // "preserve", "stereo", "mono"
CreateDownmix bool // Default: false
DownmixSingleTrack bool // Default: false
ForceTranscode bool // Default: false
QualityPreset string // "custom", "high_quality", "balanced", "small_size"
// Opus-specific
OpusApplication string // Default: "audio"
OpusVBR string // Default: "on"
}
// DefaultAudioStandardizer returns default audio standardization parameters
// Updated to align with Tdarr defaults (AAC)
func DefaultAudioStandardizer() AudioStandardizer {
return AudioStandardizer{
Codec: "aac",
SkipIfCompatible: true,
BitratePerChannel: 80,
StereoBitrate: 160,
ChannelMode: "preserve",
CreateDownmix: false,
DownmixSingleTrack: false,
ForceTranscode: false,
QualityPreset: "custom",
OpusApplication: "audio",
OpusVBR: "on",
}
}
// AudioStreamInfo contains information about an audio stream
type AudioStreamInfo struct {
Index int
Codec string
Channels int
Bitrate int
SampleRate int
Language string
ChannelLayout string
}
// AnalyzeAudioStreams analyzes audio streams in a video file
func AnalyzeAudioStreams(file string) ([]AudioStreamInfo, error) {
// Get all audio stream information using ffprobe
cmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", "a",
"-show_entries", "stream=index,codec_name,channels,bit_rate,sample_rate,channel_layout",
"-show_entries", "stream_tags=language",
"-of", "json", file)
output, err := cmd.Output()
if err != nil {
return nil, err
}
// Parse JSON output (simplified - in production, use proper JSON parsing)
// For now, we'll use a simpler approach with individual queries
var streams []AudioStreamInfo
// Get stream count first
countCmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", "a",
"-show_entries", "stream=index", "-of", "csv=p=0", file)
countOutput, err := countCmd.Output()
if err != nil {
return nil, err
}
indices := strings.Split(strings.TrimSpace(string(countOutput)), "\n")
for _, idxStr := range indices {
if idxStr == "" {
continue
}
stream := AudioStreamInfo{}
stream.Index, _ = strconv.Atoi(strings.TrimSpace(idxStr))
// Get codec
codecCmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", fmt.Sprintf("a:%d", stream.Index),
"-show_entries", "stream=codec_name", "-of", "csv=p=0", file)
if codecOut, err := codecCmd.Output(); err == nil {
stream.Codec = strings.TrimSpace(string(codecOut))
}
// Get channels
channelsCmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", fmt.Sprintf("a:%d", stream.Index),
"-show_entries", "stream=channels", "-of", "csv=p=0", file)
if channelsOut, err := channelsCmd.Output(); err == nil {
stream.Channels, _ = strconv.Atoi(strings.TrimSpace(string(channelsOut)))
}
// Get bitrate
bitrateCmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", fmt.Sprintf("a:%d", stream.Index),
"-show_entries", "stream=bit_rate", "-of", "csv=p=0", file)
if bitrateOut, err := bitrateCmd.Output(); err == nil {
stream.Bitrate, _ = strconv.Atoi(strings.TrimSpace(string(bitrateOut)))
}
// Get sample rate
sampleRateCmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", fmt.Sprintf("a:%d", stream.Index),
"-show_entries", "stream=sample_rate", "-of", "csv=p=0", file)
if sampleRateOut, err := sampleRateCmd.Output(); err == nil {
stream.SampleRate, _ = strconv.Atoi(strings.TrimSpace(string(sampleRateOut)))
}
// Get language
langCmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", fmt.Sprintf("a:%d", stream.Index),
"-show_entries", "stream_tags=language", "-of", "csv=p=0", file)
if langOut, err := langCmd.Output(); err == nil {
stream.Language = strings.TrimSpace(string(langOut))
}
// Get channel layout
layoutCmd := exec.Command("ffprobe", "-v", "quiet", "-select_streams", fmt.Sprintf("a:%d", stream.Index),
"-show_entries", "stream=channel_layout", "-of", "csv=p=0", file)
if layoutOut, err := layoutCmd.Output(); err == nil {
stream.ChannelLayout = strings.TrimSpace(string(layoutOut))
}
streams = append(streams, stream)
}
_ = output // Suppress unused variable warning
return streams, nil
}
// IsCompatibleCodec checks if a codec is compatible with the target
func IsCompatibleCodec(codec string, targetCodec string) bool {
codec = strings.ToLower(codec)
targetCodec = strings.ToLower(targetCodec)
// Opus compatibility
if targetCodec == "opus" {
return codec == "opus" || codec == "libopus"
}
// AAC compatibility
if targetCodec == "aac" {
return codec == "aac"
}
return false
}
// NeedsTranscoding checks if a stream needs transcoding
func NeedsTranscoding(stream AudioStreamInfo, standardizer AudioStandardizer) bool {
if standardizer.ForceTranscode {
return true
}
if standardizer.SkipIfCompatible {
// Accept both AAC and Opus as compatible
compatible := []string{"aac", "opus", "libopus"}
for _, c := range compatible {
if strings.ToLower(stream.Codec) == c {
return false
}
}
return true
}
// Must match exact target codec
return !IsCompatibleCodec(stream.Codec, standardizer.Codec)
}
// CalculateBitrate calculates target bitrate based on channels
func CalculateBitrate(standardizer AudioStandardizer, channels int) int {
// Check for "original" bitrate (handled as 0)
if standardizer.BitratePerChannel == 0 {
return 0 // Use original bitrate (don't specify in FFmpeg)
}
return standardizer.BitratePerChannel * channels
}
// BuildAudioCodecArgs builds FFmpeg arguments for audio encoding
func BuildAudioCodecArgs(audioIdx int, standardizer AudioStandardizer, targetBitrate int) string {
if standardizer.Codec == "opus" {
args := []string{
fmt.Sprintf("-c:a:%d", audioIdx),
"libopus",
}
if targetBitrate > 0 {
args = append(args, fmt.Sprintf("-b:a:%d", audioIdx), fmt.Sprintf("%dk", targetBitrate))
}
args = append(args,
"-vbr", standardizer.OpusVBR,
"-application", standardizer.OpusApplication,
"-compression_level", "10")
return strings.Join(args, " ")
}
// AAC
args := []string{
fmt.Sprintf("-c:a:%d", audioIdx),
"aac",
}
if targetBitrate > 0 {
args = append(args, fmt.Sprintf("-b:a:%d", audioIdx), fmt.Sprintf("%dk", targetBitrate))
}
args = append(args, "-strict", "-2")
return strings.Join(args, " ")
}
// BuildChannelArgs builds FFmpeg arguments for channel handling
func BuildChannelArgs(audioIdx int, standardizer AudioStandardizer) string {
switch standardizer.ChannelMode {
case "stereo":
return fmt.Sprintf("-ac:a:%d 2", audioIdx)
case "mono":
return fmt.Sprintf("-ac:a:%d 1", audioIdx)
default:
return "" // preserve
}
}
// OpusIncompatibleLayouts lists channel layouts incompatible with Opus
var OpusIncompatibleLayouts = []string{
"3.0(back)",
"3.0(front)",
"4.0",
"5.0(side)",
"6.0",
"6.1",
"7.0",
"7.0(front)",
}
// IsOpusIncompatibleLayout checks if a channel layout is incompatible with Opus
func IsOpusIncompatibleLayout(layout string) bool {
layout = strings.ToLower(layout)
for _, incompatible := range OpusIncompatibleLayouts {
if strings.ToLower(incompatible) == layout {
return true
}
}
return false
}
// QualityPresetConfig contains preset configurations
type QualityPresetConfig struct {
AACBitratePerChannel int
OpusBitratePerChannel int
StereoBitrate int
OpusVBR string
OpusApplication string
Description string
}
// QualityPresets contains predefined quality configurations
var QualityPresets = map[string]QualityPresetConfig{
"high_quality": {
AACBitratePerChannel: 128,
OpusBitratePerChannel: 96,
StereoBitrate: 256,
OpusVBR: "on",
OpusApplication: "audio",
Description: "Maximum quality, larger files",
},
"balanced": {
AACBitratePerChannel: 80,
OpusBitratePerChannel: 64,
StereoBitrate: 160,
OpusVBR: "on",
OpusApplication: "audio",
Description: "Good quality, reasonable file sizes",
},
"small_size": {
AACBitratePerChannel: 64,
OpusBitratePerChannel: 48,
StereoBitrate: 128,
OpusVBR: "constrained",
OpusApplication: "audio",
Description: "Smaller files, acceptable quality",
},
}
// ApplyQualityPreset applies quality preset settings to standardizer
func ApplyQualityPreset(standardizer AudioStandardizer) AudioStandardizer {
if standardizer.QualityPreset == "custom" {
return standardizer
}
preset, exists := QualityPresets[standardizer.QualityPreset]
if !exists {
return standardizer
}
// Apply preset based on codec
if standardizer.Codec == "aac" {
standardizer.BitratePerChannel = preset.AACBitratePerChannel
} else if standardizer.Codec == "opus" {
standardizer.BitratePerChannel = preset.OpusBitratePerChannel
standardizer.OpusVBR = preset.OpusVBR
standardizer.OpusApplication = preset.OpusApplication
}
standardizer.StereoBitrate = preset.StereoBitrate
return standardizer
}
// BuildDownmixArgs builds FFmpeg arguments for creating downmix tracks
func BuildDownmixArgs(audioIdx int, streamIndex int, standardizer AudioStandardizer) string {
baseArgs := fmt.Sprintf("-map 0:%d", streamIndex)
if standardizer.Codec == "opus" {
return fmt.Sprintf("%s -c:a:%d libopus -b:a:%d %dk -vbr %s -application %s -ac 2 -metadata:s:a:%d title=\"2.0 Downmix\"",
baseArgs, audioIdx, audioIdx, standardizer.StereoBitrate, standardizer.OpusVBR, standardizer.OpusApplication, audioIdx)
}
return fmt.Sprintf("%s -c:a:%d aac -b:a:%d %dk -strict -2 -ac 2 -metadata:s:a:%d title=\"2.0 Downmix\"",
baseArgs, audioIdx, audioIdx, standardizer.StereoBitrate, audioIdx)
}