// author: spijet (https://github.com/spijet/) // author: sentriz (https://github.com/sentriz/) //nolint:gochecknoglobals package transcode import ( "context" "fmt" "io" "os/exec" "time" "github.com/google/shlex" ) type Transcoder interface { Transcode(ctx context.Context, profile Profile, in string) (io.ReadCloser, error) } var UserProfiles = map[string]Profile{ "mp3": MP3, "mp3_rg": MP3RG, "opus_car": OpusCar, "opus": Opus, "opus_rg": OpusRG, } // Store as simple strings, since we may let the user provide their own profiles soon var ( MP3 = NewProfile("audio/mpeg", 128, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libmp3lame -af "volume=replaygain=track:replaygain_preamp=6dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -metadata replaygain_album_gain= -metadata replaygain_album_peak= -metadata replaygain_track_gain= -metadata replaygain_track_peak= -metadata r128_album_gain= -metadata r128_track_gain= -f mp3 -`) MP3RG = NewProfile("audio/mpeg", 128, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libmp3lame -af "volume=replaygain=track:replaygain_preamp=6dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -metadata replaygain_album_gain= -metadata replaygain_album_peak= -metadata replaygain_track_gain= -metadata replaygain_track_peak= -metadata r128_album_gain= -metadata r128_track_gain= -f mp3 -`) // this sets a baseline gain which results in the final track being +3~5dB louder than // Foobar2000's default ReplayGain target volume. // this makes it easier to listen to music in a car, where all other // sources are usually ten thousand times louder than RG-adjusted music. // // opus always forces output to 48kHz sampling rate, but we can still use upsampling // to increase RG and alimiter's peak limiting precision, which is desirable in some // cases. ffmpeg's `soxr` resampler is quite fast on x86-64: it takes around 5 seconds // on my Ryzen 3600 to transcode an 8-minute FLAC with 2x upsample and RG applied. // // -- @spijet OpusCar = NewProfile("audio/ogg", 96, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libopus -vbr on -af "aresample=96000:resampler=soxr, volume=replaygain=track:replaygain_preamp=15dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -f opus -`) Opus = NewProfile("audio/ogg", 96, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libopus -vbr on -af "volume=replaygain=track:replaygain_preamp=6dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -metadata replaygain_album_gain= -metadata replaygain_album_peak= -metadata replaygain_track_gain= -metadata replaygain_track_peak= -metadata r128_album_gain= -metadata r128_track_gain= -f opus -`) OpusRG = NewProfile("audio/ogg", 96, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libopus -vbr on -af "volume=replaygain=track:replaygain_preamp=6dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -metadata replaygain_album_gain= -metadata replaygain_album_peak= -metadata replaygain_track_gain= -metadata replaygain_track_peak= -metadata r128_album_gain= -metadata r128_track_gain= -f opus -`) PCM16le = NewProfile("audio/wav", 0, `ffmpeg -v 0 -i -ss -c:a pcm_s16le -ac 2 -f s16le -`) ) type BitRate int // kb/s type Profile struct { bitrate BitRate // the default bitrate, but the user can request a different one seek time.Duration mime string exec string } func (p *Profile) BitRate() BitRate { return p.bitrate } func (p *Profile) Seek() time.Duration { return p.seek } func (p *Profile) MIME() string { return p.mime } func NewProfile(mime string, bitrate BitRate, exec string) Profile { return Profile{mime: mime, bitrate: bitrate, exec: exec} } func WithBitrate(p Profile, bitRate BitRate) Profile { p.bitrate = bitRate return p } func WithSeek(p Profile, seek time.Duration) Profile { p.seek = seek return p } var ErrNoProfileParts = fmt.Errorf("not enough profile parts") func parseProfile(profile Profile, in string) (string, []string, error) { parts, err := shlex.Split(profile.exec) if err != nil { return "", nil, fmt.Errorf("split command: %w", err) } if len(parts) == 0 { return "", nil, ErrNoProfileParts } name, err := exec.LookPath(parts[0]) if err != nil { return "", nil, fmt.Errorf("find name: %w", err) } var args []string for _, p := range parts[1:] { switch p { case "": args = append(args, in) case "": args = append(args, fmt.Sprintf("%dus", profile.Seek().Microseconds())) case "": args = append(args, fmt.Sprintf("%dk", profile.BitRate())) default: args = append(args, p) } } return name, args, nil } // GuessExpectedSize guesses how big the transcoded file will be in bytes. // Handy if we want to send a Content-Length header to the client before // the transcode has finished. This way, clients like DSub can render their // scrub bar and duration as the track is streaming. // // The estimate should overshoot a bit (2s in this case) otherwise some HTTP // clients will shit their trousers given some unexpected bytes. func GuessExpectedSize(profile Profile, length time.Duration) int { if length == 0 { return 0 } bytesPerSec := int(profile.BitRate() * 1000 / 8) var guess int guess += bytesPerSec * int(length.Seconds()-profile.seek.Seconds()) guess += bytesPerSec * 2 // 2s pading guess += 10000 // 10kb byte padding return guess }