upgrade: gwencoder v3.1 with interactive menu and Tdarr alignment
This commit is contained in:
323
gwencoder/pkg/encoding/audio.go
Executable file
323
gwencoder/pkg/encoding/audio.go
Executable file
@@ -0,0 +1,323 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user