mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-17 13:10:27 -04:00
* fix(transcoding): clamp target channels to codec limit (#5336) When transcoding a multi-channel source (e.g. 6-channel FLAC) to MP3, the decider passed the source channel count through to ffmpeg unchanged. The default MP3 command path then emitted `-ac 6`, and the template path injected `-ac 6` after the template's own `-ac 2`, causing ffmpeg to honor the last occurrence and fail with exit code 234 since libmp3lame only supports up to 2 channels. Introduce `codecMaxChannels()` in core/stream/codec.go (mp3→2, opus→8), mirroring the existing `codecMaxSampleRate` pattern, and apply the clamp in `computeTranscodedStream` right after the sample-rate clamps. Also fix a pre-existing ordering bug where the profile's MaxAudioChannels check compared against src.Channels rather than ts.Channels, which would have let a looser profile setting raise the codec-clamped value back up. Comparing against the already-clamped ts.Channels makes profile limits strictly narrowing, which matches how the sample-rate block already behaves. The ffmpeg buildTemplateArgs comment is refreshed to point at the new upstream clamp, since the flags it injects are now always codec-safe. Adds unit tests for codecMaxChannels and four decider scenarios covering the literal issue repro (6-ch FLAC→MP3 clamps to 2), a stricter profile limit winning over the codec clamp, a looser profile limit leaving the codec clamp intact, and a codec with no hard limit (AAC) passing 6 channels through. * test(e2e): pin codec channel clamp at the Subsonic API surface (#5336) Add a 6-channel FLAC fixture to the e2e test suite and use it to assert the codec channel clamp end-to-end on both Subsonic streaming endpoints: - getTranscodeDecision (mp3OnlyClient, no MaxAudioChannels in profile): expects TranscodeStream.AudioChannels == 2 for the 6-channel source. This exercises the new codecMaxChannels() helper through the OpenSubsonic decision endpoint, with no profile-level channel limit masking the bug. - /rest/stream (legacy): requests format=mp3 against the multichannel fixture and asserts streamerSpy.LastRequest.Channels == 2, confirming the clamp propagates through ResolveRequest into the stream.Request that the streamer receives. The fixture is metadata-only (channels: 6 plumbed via the existing storagetest.File helper) — no real audio bytes required, since the e2e suite uses a spy streamer rather than invoking ffmpeg. Bumps the empty-query search3 song count expectation from 13 to 14 to account for the new fixture. * test(decider): clarify codec-clamp comment terminology Distinguish "transcoding profile MaxAudioChannels" (Profile.MaxAudioChannels field) from "LimitationAudioChannels" (CodecProfile rule constant). The regression test bypasses the former, not the latter.
91 lines
2.9 KiB
Go
91 lines
2.9 KiB
Go
package stream
|
|
|
|
import "strings"
|
|
|
|
// normalizeProbeCodec maps ffprobe codec_name values to the simplified internal
|
|
// codec names used throughout Navidrome (matching inferCodecFromSuffix output).
|
|
// Most ffprobe names match directly; this handles the exceptions.
|
|
func normalizeProbeCodec(codec string) string {
|
|
c := strings.ToLower(codec)
|
|
// DSD variants: dsd_lsbf_planar, dsd_msbf_planar, dsd_lsbf, dsd_msbf
|
|
if strings.HasPrefix(c, "dsd") {
|
|
return "dsd"
|
|
}
|
|
// PCM variants: pcm_s16le, pcm_s24le, pcm_s32be, pcm_f32le, etc.
|
|
if strings.HasPrefix(c, "pcm_") {
|
|
return "pcm"
|
|
}
|
|
return c
|
|
}
|
|
|
|
// isLosslessFormat returns true if the format is a known lossless audio codec/format.
|
|
// Detection is based on codec name only, not bit depth — some lossy codecs (e.g. ADPCM)
|
|
// report non-zero bits_per_sample in ffprobe, so bit depth alone is not a reliable signal.
|
|
//
|
|
// Note: core/ffmpeg has a separate isLosslessOutputFormat that covers only formats
|
|
// ffmpeg can produce as output (a smaller set).
|
|
func isLosslessFormat(format string) bool {
|
|
switch strings.ToLower(format) {
|
|
case "flac", "alac", "wav", "aiff", "ape", "wv", "wavpack", "tta", "tak", "shn", "dsd", "pcm":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// normalizeSourceSampleRate adjusts the source sample rate for codecs that store
|
|
// it differently than PCM. Currently handles DSD (÷8):
|
|
// DSD64=2822400→352800, DSD128=5644800→705600, etc.
|
|
// For other codecs, returns the rate unchanged.
|
|
func normalizeSourceSampleRate(sampleRate int, codec string) int {
|
|
if strings.EqualFold(codec, "dsd") && sampleRate > 0 {
|
|
return sampleRate / 8
|
|
}
|
|
return sampleRate
|
|
}
|
|
|
|
// normalizeSourceBitDepth adjusts the source bit depth for codecs that use
|
|
// non-standard bit depths. Currently handles DSD (1-bit → 24-bit PCM, which is
|
|
// what ffmpeg produces). For other codecs, returns the depth unchanged.
|
|
func normalizeSourceBitDepth(bitDepth int, codec string) int {
|
|
if strings.EqualFold(codec, "dsd") && bitDepth == 1 {
|
|
return 24
|
|
}
|
|
return bitDepth
|
|
}
|
|
|
|
// codecFixedOutputSampleRate returns the mandatory output sample rate for codecs
|
|
// that always resample regardless of input (e.g., Opus always outputs 48000Hz).
|
|
// Returns 0 if the codec has no fixed output rate.
|
|
func codecFixedOutputSampleRate(codec string) int {
|
|
switch strings.ToLower(codec) {
|
|
case "opus":
|
|
return 48000
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// codecMaxSampleRate returns the hard maximum output sample rate for a codec.
|
|
// Returns 0 if the codec has no hard limit.
|
|
func codecMaxSampleRate(codec string) int {
|
|
switch strings.ToLower(codec) {
|
|
case "mp3":
|
|
return 48000
|
|
case "aac":
|
|
return 96000
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// codecMaxChannels returns the hard maximum number of audio channels a codec
|
|
// supports. Returns 0 if the codec has no hard limit (or is unknown), in which
|
|
// case the source/profile constraints applied upstream are authoritative.
|
|
func codecMaxChannels(codec string) int {
|
|
switch strings.ToLower(codec) {
|
|
case "mp3":
|
|
return 2
|
|
case "opus":
|
|
return 8
|
|
}
|
|
return 0
|
|
}
|