From 8eb0bf95c6734d056d643fc291f4a4c73413b02b Mon Sep 17 00:00:00 2001 From: WeebDataHoarder <57538841+WeebDataHoarder@users.noreply.github.com> Date: Sun, 15 May 2022 16:42:28 +0200 Subject: [PATCH] Updated Kirika, new option system for codecs --- README.md | 83 ++++++++++++++++++++++++++++++++ api.go | 6 +-- config.go | 97 +++++++++++++++++++++++++++++++++++--- example_config.toml | 18 +++++-- go.mod | 11 +++-- go.sum | 27 +++++++---- mount.go | 112 ++++++++++++++++++++++++++------------------ queue.go | 68 ++++++++++++++++++++++----- 8 files changed, 338 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 1c7f229..c6aa7ab 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Radio streamer ([kawa](https://github.com/Luminarys/kawa) drop-in compatible). * Implements `queue.nr` and `/random` (to be deprecated/changed) * Supports max queue length via `queue.length` config. * Supports extra encoder bitrate control settings (CBR, VBR, auto, etc.) +* Can set custom sample rate / channel count / bitdepth / compression level per stream mount. * Can read and apply ReplayGain tags, or normalize audio loudness. * Can have audio sources over HTTP(s) URLs on `path` property, and supports seeking. * Precise metadata and timing information packet stream, trigger via `x-audio-packet-stream: 1` HTTP header. @@ -93,4 +94,86 @@ Same as kawa's, but `queue_id` is added to response directly. "reason": null, "queue_id": 5 } +``` + +## Mount API +### `NEW` GET /mounts +A simple listing of the working mounts + settings are made available. + +#### Response +```json +[ + { + "mount": "/stream128.mp3", + "mime": "audio/mpeg;codecs=mp3", + "sampleRate": 44100, + "channels": 2, + "listeners": 0, + "options": { + "bitrate": 128 + } + }, + { + "mount": "/stream192.mp3", + "mime": "audio/mpeg;codecs=mp3", + "sampleRate": 44100, + "channels": 2, + "listeners": 0, + "options": { + "bitrate": 192 + } + }, + { + "mount": "/stream128.aac", + "mime": "audio/aac", + "sampleRate": 44100, + "channels": 2, + "listeners": 0, + "options": { + "bitrate": 128 + } + }, + { + "mount": "/stream128.opus", + "mime": "audio/ogg;codecs=opus", + "sampleRate": 48000, + "channels": 2, + "listeners": 0, + "options": { + "bitrate": 128 + } + }, + { + "mount": "/stream192.opus", + "mime": "audio/ogg;codecs=opus", + "sampleRate": 48000, + "channels": 2, + "listeners": 0, + "options": { + "bitrate": 192 + } + }, + { + "mount": "/stream256.opus", + "mime": "audio/ogg;codecs=opus", + "sampleRate": 48000, + "channels": 2, + "listeners": 0, + "options": { + "bitrate": 256 + } + }, + { + "mount": "/stream.flac", + "mime": "audio/flac", + "sampleRate": 44100, + "channels": 2, + "listeners": 0, + "options": { + "bitdepth": 16, + "block_size": 0, + "compression_level": 5 + } + } +] ``` \ No newline at end of file diff --git a/api.go b/api.go index d285daa..b8e93c9 100644 --- a/api.go +++ b/api.go @@ -4,7 +4,7 @@ import ( "bytes" "encoding/json" "fmt" - "git.gammaspectra.live/S.O.N.G/Kirika/audio" + "git.gammaspectra.live/S.O.N.G/Kirika/audio/queue" "io/ioutil" "log" "net/http" @@ -152,7 +152,7 @@ func (a *API) listen() { type queueResultResponse struct { resultResponse - QueueId audio.QueueIdentifier `json:"queue_id"` + QueueId queue.QueueIdentifier `json:"queue_id"` } a.wg.Add(1) @@ -290,7 +290,7 @@ func (a *API) listen() { if i, err := strconv.ParseInt(pathSegments[2], 10, 0); err == nil { result := resultResponse{} - result.Success = a.queue.Remove(audio.QueueIdentifier(i)) + result.Success = a.queue.Remove(queue.QueueIdentifier(i)) jsonData, _ := json.Marshal(result) writer.Write(jsonData) diff --git a/config.go b/config.go index 705707d..c79517e 100644 --- a/config.go +++ b/config.go @@ -1,6 +1,10 @@ package main -import "github.com/BurntSushi/toml" +import ( + "errors" + "github.com/BurntSushi/toml" + "strconv" +) type Config struct { Api struct { @@ -16,6 +20,7 @@ type Config struct { BufferSeconds int `toml:"buffer_size"` ReplayGain bool `toml:"replaygain"` Length int `toml:"length"` + SampleRate int `toml:"samplerate"` } `toml:"queue"` Radio struct { Port int `toml:"port"` @@ -25,12 +30,90 @@ type Config struct { Logo string `toml:"logo"` Private bool `toml:"private"` } `toml:"radio"` - Streams []struct { - MountPath string `toml:"mount"` - Container string `toml:"container"` - Bitrate interface{} `toml:"bitrate"` - Codec string `toml:"codec"` - } `toml:"streams"` + Streams []StreamConfig `toml:"streams"` +} + +type StreamConfig struct { + MountPath string + Container string + Codec string + + Options map[string]interface{} +} + +func (s *StreamConfig) GetIntOption(name string, defaultValue int) int { + if v, ok := s.Options[name].(int); ok { + return v + } + + if v, ok := s.Options[name].(int64); ok { + return int(v) + } + + if v, ok := s.Options[name].(string); ok { + if val, err := strconv.Atoi(v); err == nil { + return val + } + } + + return defaultValue +} + +func (s *StreamConfig) GetStringOption(name string, defaultValue string) string { + if v, ok := s.Options[name].(string); ok { + return v + } + + if v, ok := s.Options[name].(int); ok { + return strconv.Itoa(v) + } + + if v, ok := s.Options[name].(int64); ok { + return strconv.Itoa(int(v)) + } + + return defaultValue +} + +func (s *StreamConfig) GetOption(name string, defaultValue interface{}) interface{} { + + if v, ok := s.Options[name]; ok { + return v + } + + return defaultValue +} + +func (s *StreamConfig) UnmarshalTOML(value interface{}) error { + if v, ok := value.(map[string]interface{}); ok { + + if mount, ok := v["mount"].(string); ok { + s.MountPath = mount + } else { + return errors.New("could not find mount path") + } + + if container, ok := v["container"].(string); ok { + s.Container = container + } + + if codec, ok := v["codec"].(string); ok { + s.Codec = codec + } + + s.Options = make(map[string]interface{}) + + for k, val := range v { + if k == "mount" || k == "container" || k == "codec" { + continue + } + s.Options[k] = val + } + + return nil + } + + return errors.New("could not decode") } func GetConfig(pathName string) (*Config, error) { diff --git a/example_config.toml b/example_config.toml index a77a317..b10cf81 100644 --- a/example_config.toml +++ b/example_config.toml @@ -53,6 +53,10 @@ buffer_duration=0 # } replaygain=false +# Set the sample rate of the queue. Default is 44100 +# Some codecs (example: Opus) will output at a different samplerate. +#samplerate=44100 + [radio] # # The port to stream actual audio on. MeteorLight will listen on localhost. @@ -73,11 +77,15 @@ private=false # following properties are available: # # mount: the HTTP address to serve the stream from -# container: the container format to use (ogg, flac, aac, or mp3). See Kirika's supported format list. -# codec: the audio codec to use (opus, flac, aac, do not specify for mp3 streams) +# container: the container format to use (ogg, flac, aac/adts, or mp3). See Kirika's supported format list. +# codec: the audio codec to use (opus, flac, aac, he-aacv2, or mp3) # bitrate: the desired bitrate of the stream in Kb/s, if not specified (or 0) an appropriate # bitrate will be automatically selected based on the container/codec -# MeteorLight extension: bitrate can be a string (for example, v0/v1/v2/v3 on MP3). codec can also be he-aacv2. No vorbis support +# MeteorLight extension: bitrate can be a string (for example, v0/v1/v2/v3 on MP3). codec can also be he-aacv2. No vorbis support. +# Additionally further options can be set on some codecs. +# - FLAC supports bitdepth (int), compression_level (int), block_size (int) +# - All but Opus support setting samplerate +# - All support setting channels to 1 or 2 [[streams]] mount="stream128.mp3" container="mp3" @@ -90,6 +98,8 @@ bitrate=192 [[streams]] mount="stream128.aac" +# Also supports he-aacv2 +codec="aac" container="aac" bitrate=128 @@ -113,4 +123,6 @@ bitrate=256 [[streams]] mount="stream.flac" +codec="flac" +# Also supports ogg container="flac" \ No newline at end of file diff --git a/go.mod b/go.mod index 54177fd..9972eff 100644 --- a/go.mod +++ b/go.mod @@ -3,21 +3,22 @@ module git.gammaspectra.live/S.O.N.G/MeteorLight go 1.18 require ( - git.gammaspectra.live/S.O.N.G/Kirika v0.0.0-20220421115636-4612766b64e8 + git.gammaspectra.live/S.O.N.G/Kirika v0.0.0-20220515143733-468ec9b491d1 github.com/BurntSushi/toml v1.1.0 github.com/dhowden/tag v0.0.0-20201120070457-d52dcb253c63 github.com/enriquebris/goconcurrentqueue v0.6.3 ) require ( - git.gammaspectra.live/S.O.N.G/go-alac v0.0.0-20220421110341-7839cd4c1da1 // indirect + git.gammaspectra.live/S.O.N.G/go-alac v0.0.0-20220421115623-d0b3bfe57e0f // indirect git.gammaspectra.live/S.O.N.G/go-ebur128 v0.0.0-20220418202343-73a167e76255 // indirect - git.gammaspectra.live/S.O.N.G/go-fdkaac v0.0.0-20220417020459-7018d91e3eed // indirect + git.gammaspectra.live/S.O.N.G/go-fdkaac v0.0.0-20220421165127-c4b73b260d94 // indirect git.gammaspectra.live/S.O.N.G/go-pus v0.0.0-20220227175608-6cc027f24dba // indirect git.gammaspectra.live/S.O.N.G/go-tta v0.2.1-0.20220226150007-096de1072bd6 // indirect git.gammaspectra.live/S.O.N.G/goflac v0.0.0-20220417181802-3057bde44c07 // indirect github.com/dh1tw/gosamplerate v0.1.2 // indirect - github.com/edgeware/mp4ff v0.27.0 // indirect + github.com/edgeware/mp4ff v0.28.0 // indirect + github.com/gen2brain/aac-go v0.0.0-20180306134136-400c68157565 // indirect github.com/icza/bitio v1.1.0 // indirect github.com/jfreymuth/oggvorbis v1.0.3 // indirect github.com/jfreymuth/vorbis v1.0.2 // indirect @@ -27,4 +28,6 @@ require ( github.com/mewkiz/pkg v0.0.0-20211102230744-16a6ce8f1b77 // indirect github.com/sssgun/mp3 v0.0.0-20170810093403-85f2ec632081 // indirect github.com/viert/go-lame v0.0.0-20201108052322-bb552596b11d // indirect + github.com/youpy/go-riff v0.1.0 // indirect + github.com/zaf/g711 v0.0.0-20220109202201-cf0017bf0359 // indirect ) diff --git a/go.sum b/go.sum index b56940f..2044661 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,11 @@ -git.gammaspectra.live/S.O.N.G/Kirika v0.0.0-20220421115636-4612766b64e8 h1:OMLUwQmbveOsXlQU0XUfA7tZMAzz98B7rac4GKmrY90= -git.gammaspectra.live/S.O.N.G/Kirika v0.0.0-20220421115636-4612766b64e8/go.mod h1:YAH1ahOjNcVgGGeudk5q0bNqcSesc19ItsQeiuNART8= -git.gammaspectra.live/S.O.N.G/go-alac v0.0.0-20220421110341-7839cd4c1da1 h1:D1VyacBGUBfvFD4Fq2eO6RQ5eZPUdC2YsIv9gXun9aQ= -git.gammaspectra.live/S.O.N.G/go-alac v0.0.0-20220421110341-7839cd4c1da1/go.mod h1:f1+h7KOnuM9zcEQp7ri4UaVvgX4m1NFFIXgReIyjGMA= +git.gammaspectra.live/S.O.N.G/Kirika v0.0.0-20220515143733-468ec9b491d1 h1:3k49C82QXtvONyQk51XPz2cGIOkmhmmMAggWM/RW8tg= +git.gammaspectra.live/S.O.N.G/Kirika v0.0.0-20220515143733-468ec9b491d1/go.mod h1:65QohfAwsgMkXS7PZO2cm8VgPEZlhKC+JAeZfJBYpFE= +git.gammaspectra.live/S.O.N.G/go-alac v0.0.0-20220421115623-d0b3bfe57e0f h1:CxN7zlk5FdAieyRKQSbwBGBsvQ2cDF8JVCODZpzcRkA= +git.gammaspectra.live/S.O.N.G/go-alac v0.0.0-20220421115623-d0b3bfe57e0f/go.mod h1:f1+h7KOnuM9zcEQp7ri4UaVvgX4m1NFFIXgReIyjGMA= git.gammaspectra.live/S.O.N.G/go-ebur128 v0.0.0-20220418202343-73a167e76255 h1:BWRx2ZFyhp5+rsXhdDZtk5Gld+L44lxlN9ASqB9Oj0M= git.gammaspectra.live/S.O.N.G/go-ebur128 v0.0.0-20220418202343-73a167e76255/go.mod h1:5H4eVW9uknpn8REFr+C3ejhvXdncgm/pbGqKGC43gFY= -git.gammaspectra.live/S.O.N.G/go-fdkaac v0.0.0-20220417020459-7018d91e3eed h1:aJPCb3LS4Wai34S5bKUGgAm+/hPt0J2tvWPOl6RnbbE= -git.gammaspectra.live/S.O.N.G/go-fdkaac v0.0.0-20220417020459-7018d91e3eed/go.mod h1:pkWt//S9hLVEQaJDPu/cHHPk8vPpo/0+zHy0me4LIP4= +git.gammaspectra.live/S.O.N.G/go-fdkaac v0.0.0-20220421165127-c4b73b260d94 h1:gD6lOyQwwuyJilwbLC8lAEWpUSZvndJsUGwxxDatCAE= +git.gammaspectra.live/S.O.N.G/go-fdkaac v0.0.0-20220421165127-c4b73b260d94/go.mod h1:pkWt//S9hLVEQaJDPu/cHHPk8vPpo/0+zHy0me4LIP4= git.gammaspectra.live/S.O.N.G/go-pus v0.0.0-20220227175608-6cc027f24dba h1:JEaxCVgdr3XXAuDCPAx7ttLFZaaHzTEzG+oRnVUtUKU= git.gammaspectra.live/S.O.N.G/go-pus v0.0.0-20220227175608-6cc027f24dba/go.mod h1:vkoHSHVM9p6vAUmXAik0gvaLcIfiQYrD6bQqVpOulUk= git.gammaspectra.live/S.O.N.G/go-tta v0.2.1-0.20220226150007-096de1072bd6 h1:ITVVisbHPnUclp3PBkCbXFeBhOCBcOjPdgjJ9wRH3TI= @@ -21,15 +21,17 @@ github.com/dh1tw/gosamplerate v0.1.2 h1:oyqtZk67xB9B4l+vIZCZ3F0RYV/z66W58VOah11/ github.com/dh1tw/gosamplerate v0.1.2/go.mod h1:zooTyHpoR7hE+FLfdE3yjLHb2QA2NpMusNfuaZqEACM= github.com/dhowden/tag v0.0.0-20201120070457-d52dcb253c63 h1:/u5RVRk3Nh7Zw1QQnPtUH5kzcc8JmSSRpHSlGU/zGTE= github.com/dhowden/tag v0.0.0-20201120070457-d52dcb253c63/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw= -github.com/edgeware/mp4ff v0.27.0 h1:Lv773H6c4pUt3Zj9z44TOICv6fjd6gKzi1sVl8GbcYE= -github.com/edgeware/mp4ff v0.27.0/go.mod h1:6VHE5CTkpDseIg775+rh8BfnTvqjMnVbz5EDU4QwSdc= +github.com/edgeware/mp4ff v0.28.0 h1:iAtPUXtENORvCPCLIdDNeh5Lb6GXS1hIyYUx2iBkygE= +github.com/edgeware/mp4ff v0.28.0/go.mod h1:GNUeA6tEFksH2CrjJF2FSGdJolba8yPGmo16qZTXsm8= github.com/enriquebris/goconcurrentqueue v0.6.3 h1:+ma7EEEFMDmJBIS6Q4KNJruChctgwYQFqlxdveIoEE4= github.com/enriquebris/goconcurrentqueue v0.6.3/go.mod h1:OZ+KC2BcRYzjg0vgoUs1GFqdAjkD9mz2Ots7Jbm1yS4= +github.com/gen2brain/aac-go v0.0.0-20180306134136-400c68157565 h1:5cGj3eU0uEzpHDFwqmcKc6zM1+HNvzPIR6ZY7T1/MsA= +github.com/gen2brain/aac-go v0.0.0-20180306134136-400c68157565/go.mod h1:Tymn6PFuf0SYIYLOWIZWtatGOmi/5FLUNawJt9OgxJw= github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE= -github.com/go-test/deep v1.0.6 h1:UHSEyLZUwX9Qoi99vVwvewiMC8mM2bf7XEM2nqvzEn8= -github.com/go-test/deep v1.0.6/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0= github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= @@ -56,6 +58,11 @@ github.com/sssgun/mp3 v0.0.0-20170810093403-85f2ec632081/go.mod h1:ExwZtltybPz8z github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/viert/go-lame v0.0.0-20201108052322-bb552596b11d h1:LptdD7GTUZeklomtW5vZ1AHwBvDBUCZ2Ftpaz7uEI7g= github.com/viert/go-lame v0.0.0-20201108052322-bb552596b11d/go.mod h1:EqTcYM7y4JlSfeTI47pmNu3EZQuCuLQefsQyg1Imlz8= +github.com/youpy/go-riff v0.1.0 h1:vZO/37nI4tIET8tQI0Qn0Y79qQh99aEpponTPiPut7k= +github.com/youpy/go-riff v0.1.0/go.mod h1:83nxdDV4Z9RzrTut9losK7ve4hUnxUR8ASSz4BsKXwQ= +github.com/youpy/go-wav v0.3.2 h1:NLM8L/7yZ0Bntadw/0h95OyUsen+DQIVf9gay+SUsMU= +github.com/zaf/g711 v0.0.0-20220109202201-cf0017bf0359 h1:P9yeMx2iNJxJqXEwLtMjSwWcD2a0AlFmFByeosMZhLM= +github.com/zaf/g711 v0.0.0-20220109202201-cf0017bf0359/go.mod h1:ySLGJD8AQluMQuu5JDvfJrwsBra+8iX1jFsKS8KfB2I= golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/mount.go b/mount.go index 4343926..f9b046b 100644 --- a/mount.go +++ b/mount.go @@ -2,6 +2,7 @@ package main import ( "git.gammaspectra.live/S.O.N.G/Kirika/audio" + "git.gammaspectra.live/S.O.N.G/Kirika/audio/filter" "git.gammaspectra.live/S.O.N.G/Kirika/audio/format" "git.gammaspectra.live/S.O.N.G/Kirika/audio/format/aac" "git.gammaspectra.live/S.O.N.G/Kirika/audio/format/flac" @@ -32,18 +33,20 @@ type StreamListener struct { Close func() } type StreamMount struct { - Mount string - MimeType string - Packetizer packetizer.Packetizer - Options map[string]interface{} - SampleRate int - MetadataQueue *goconcurrentqueue.FIFO - listeners []*StreamListener - listenersLock sync.Mutex - keepBuffer []packetizer.Packet + Mount string + MimeType string + FormatDescription string + Packetizer packetizer.Packetizer + Options map[string]interface{} + SampleRate int + Channels int + MetadataQueue *goconcurrentqueue.FIFO + listeners []*StreamListener + listenersLock sync.Mutex + keepBuffer []packetizer.Packet } -func NewStreamMount(source audio.Source, mount string, codec string, container string, bitrate interface{}) *StreamMount { +func NewStreamMount(source audio.Source, config *StreamConfig) *StreamMount { var encoderFormat format.Encoder options := make(map[string]interface{}) var mimeType string @@ -51,14 +54,21 @@ func NewStreamMount(source audio.Source, mount string, codec string, container s reader, writer := io.Pipe() var packetizerEntry packetizer.Packetizer - sampleRate := source.SampleRate + bitrate := config.GetOption("bitrate", nil) - switch codec { + sampleRate := config.GetIntOption("samplerate", source.SampleRate) + + channels := config.GetIntOption("channels", source.Channels) + + switch config.Codec { + case "vorbis": + return nil case "opus": encoderFormat = opus.NewFormat() if bitrate != nil { options["bitrate"] = bitrate } + sampleRate = opus.FixedSampleRate mimeType = "audio/ogg;codecs=opus" packetizerEntry = packetizer.NewOggPacketizer(reader) case "mp3": @@ -70,18 +80,23 @@ func NewStreamMount(source audio.Source, mount string, codec string, container s packetizerEntry = packetizer.NewMp3Packetizer(reader) case "flac": encoderFormat = flac.NewFormat() - if bitrate != nil { - options["bitdepth"] = bitrate - } - options["compression_level"] = 8 + + options["bitdepth"] = config.GetIntOption("bitdepth", 16) + options["compression_level"] = config.GetIntOption("compression_level", 8) + options["block_size"] = config.GetIntOption("block_size", 0) + mimeType = "audio/flac" + if config.Container == "ogg" { + options["ogg"] = true + mimeType = "audio/ogg;codecs=flac" + } packetizerEntry = packetizer.NewFLACPacketizer(reader) case "aac": encoderFormat = aac.NewFormat() if bitrate != nil { options["bitrate"] = bitrate } - mimeType = "audio/aac" + mimeType = "audio/aac;codecs=mp4a.40.2" packetizerEntry = packetizer.NewAdtsPacketizer(reader) case "he-aacv2": encoderFormat = aac.NewFormat() @@ -89,17 +104,18 @@ func NewStreamMount(source audio.Source, mount string, codec string, container s options["bitrate"] = bitrate } options["mode"] = "hev2" - mimeType = "audio/aac" + mimeType = "audio/aac;codecs=mp4a.40.29" packetizerEntry = packetizer.NewAdtsPacketizer(reader) } if encoderFormat == nil { - switch container { + switch config.Container { case "ogg": encoderFormat = opus.NewFormat() if bitrate != nil { options["bitrate"] = bitrate } + sampleRate = opus.FixedSampleRate mimeType = "audio/ogg;codecs=opus" packetizerEntry = packetizer.NewOggPacketizer(reader) case "mp3": @@ -111,10 +127,11 @@ func NewStreamMount(source audio.Source, mount string, codec string, container s packetizerEntry = packetizer.NewMp3Packetizer(reader) case "flac": encoderFormat = flac.NewFormat() - if bitrate != nil { - options["bitdepth"] = bitrate - } - options["compression_level"] = 8 + + options["bitdepth"] = config.GetIntOption("bitdepth", 16) + options["compression_level"] = config.GetIntOption("compression_level", 8) + options["block_size"] = config.GetIntOption("block_size", 0) + mimeType = "audio/flac" packetizerEntry = packetizer.NewFLACPacketizer(reader) case "adts", "aac": @@ -122,7 +139,7 @@ func NewStreamMount(source audio.Source, mount string, codec string, container s if bitrate != nil { options["bitrate"] = bitrate } - mimeType = "audio/aac" + mimeType = "audio/aac;codecs=mp4a.40.2" packetizerEntry = packetizer.NewAdtsPacketizer(reader) } } @@ -131,30 +148,35 @@ func NewStreamMount(source audio.Source, mount string, codec string, container s return nil } - if opusFormat, ok := encoderFormat.(opus.Format); ok { - sampleRate = opus.FixedSampleRate - go func() { - defer writer.Close() - if err := opusFormat.Encode(audio.NewResampleFilter(opus.FixedSampleRate, audio.Linear, 0).Process(source), writer, options); err != nil { - log.Panic(err) + go func() { + defer writer.Close() + + if channels != source.Channels { + if channels == 1 { + source = filter.MonoFilter{}.Process(source) + } else if channels == 2 { + source = filter.StereoFilter{}.Process(source) } - }() - } else { - go func() { - defer writer.Close() - if err := encoderFormat.Encode(source, writer, options); err != nil { - log.Panic(err) - } - }() - } + } + + if sampleRate != source.SampleRate { + source = filter.NewResampleFilter(sampleRate, filter.Linear, 0).Process(source) + } + + if err := encoderFormat.Encode(source, writer, options); err != nil { + log.Panic(err) + } + }() return &StreamMount{ - Mount: mount, - MimeType: mimeType, - Packetizer: packetizerEntry, - SampleRate: sampleRate, - Options: options, - MetadataQueue: goconcurrentqueue.NewFIFO(), + Mount: config.MountPath, + MimeType: mimeType, + FormatDescription: encoderFormat.Description(), + Packetizer: packetizerEntry, + SampleRate: sampleRate, + Channels: channels, + Options: options, + MetadataQueue: goconcurrentqueue.NewFIFO(), } } diff --git a/queue.go b/queue.go index 9a7691f..98cdbe6 100644 --- a/queue.go +++ b/queue.go @@ -7,8 +7,10 @@ import ( "errors" "fmt" "git.gammaspectra.live/S.O.N.G/Kirika/audio" + "git.gammaspectra.live/S.O.N.G/Kirika/audio/filter" "git.gammaspectra.live/S.O.N.G/Kirika/audio/format/guess" "git.gammaspectra.live/S.O.N.G/Kirika/audio/packetizer" + "git.gammaspectra.live/S.O.N.G/Kirika/audio/queue" "git.gammaspectra.live/S.O.N.G/Kirika/audio/replaygain" "github.com/dhowden/tag" "io" @@ -26,7 +28,7 @@ import ( const maxBufferSize = 10 type QueueTrackEntry struct { - QueueIdentifier audio.QueueIdentifier + QueueIdentifier queue.QueueIdentifier Path string Metadata struct { Title string `json:"title"` @@ -180,7 +182,7 @@ type Queue struct { QueueEmpty chan *QueueTrackEntry Duration time.Duration durationError int64 - audioQueue *audio.Queue + audioQueue *queue.Queue mounts []*StreamMount queue []*QueueTrackEntry mutex sync.RWMutex @@ -189,17 +191,20 @@ type Queue struct { } func NewQueue(config *Config) *Queue { + if config.Queue.SampleRate <= 0 { + config.Queue.SampleRate = 44100 + } q := &Queue{ NowPlaying: make(chan *QueueTrackEntry, 1), QueueEmpty: make(chan *QueueTrackEntry), config: config, - audioQueue: audio.NewQueue(44100, 2), + audioQueue: queue.NewQueue(config.Queue.SampleRate, 2), } blocksPerSecond := 20 - sources := SplitAudioSource(audio.NewFilterChain(q.audioQueue.GetSource(), audio.NewBufferFilter(16), audio.NewRealTimeFilter(blocksPerSecond), audio.NewBufferFilter(maxBufferSize*blocksPerSecond)), len(config.Streams)) + sources := SplitAudioSource(filter.NewFilterChain(q.audioQueue.GetSource(), filter.NewBufferFilter(16), filter.NewRealTimeFilter(blocksPerSecond), filter.NewBufferFilter(maxBufferSize*blocksPerSecond)), len(config.Streams)) for i, s := range q.config.Streams { - mount := NewStreamMount(sources[i], s.MountPath, s.Codec, s.Container, s.Bitrate) + mount := NewStreamMount(sources[i], &s) if mount == nil { log.Panicf("could not initialize %s\n", s.MountPath) } @@ -222,7 +227,7 @@ func (q *Queue) AddTrack(entry *QueueTrackEntry, tail bool) error { return err } - startCallback := func(queue *audio.Queue, queueEntry *audio.QueueEntry) { + startCallback := func(queue *queue.Queue, queueEntry *queue.QueueEntry) { if e := q.Get(queueEntry.Identifier); e != nil { //is this needed? log.Printf("now playing %s\n", e.Path) q.NowPlaying <- e @@ -238,11 +243,11 @@ func (q *Queue) AddTrack(entry *QueueTrackEntry, tail bool) error { } } - endCallback := func(queue *audio.Queue, entry *audio.QueueEntry) { + endCallback := func(queue *queue.Queue, entry *queue.QueueEntry) { } - removeCallback := func(queue *audio.Queue, entry *audio.QueueEntry) { + removeCallback := func(queue *queue.Queue, entry *queue.QueueEntry) { atomic.AddInt64((*int64)(&q.Duration), int64((time.Second*time.Duration(entry.ReadSamples))/time.Duration(entry.Source.SampleRate))) q.Remove(entry.Identifier) @@ -307,7 +312,7 @@ func (q *Queue) GetQueue() (result []*QueueTrackEntry) { return } -func (q *Queue) Get(identifier audio.QueueIdentifier) *QueueTrackEntry { +func (q *Queue) Get(identifier queue.QueueIdentifier) *QueueTrackEntry { q.mutex.RLock() defer q.mutex.RUnlock() for _, e := range q.queue { @@ -359,7 +364,7 @@ func (q *Queue) GetTail() *QueueTrackEntry { return nil } -func (q *Queue) Remove(identifier audio.QueueIdentifier) bool { +func (q *Queue) Remove(identifier queue.QueueIdentifier) bool { q.mutex.Lock() for i, e := range q.queue { if e.QueueIdentifier == identifier { @@ -434,6 +439,45 @@ func (p *packetStreamFrame) Encode() []byte { } func (q *Queue) HandleRadioRequest(writer http.ResponseWriter, request *http.Request) { + if strings.HasSuffix(request.URL.Path, "mounts") { + writer.Header().Set("Server", "MeteorLight/radio") + writer.Header().Set("Accept-Ranges", "none") + writer.Header().Set("Connection", "close") + writer.Header().Set("Access-Control-Allow-Origin", "*") + writer.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Icy-Metadata") + writer.Header().Set("Access-Control-Expose-Headers", "Accept-Ranges, Server, Content-Type") + + type mountData struct { + Path string `json:"mount"` + MimeType string `json:"mime"` + FormatDescription string `json:"formatDescription"` + SampleRate int `json:"sampleRate"` + Channels int `json:"channels"` + Listeners int `json:"listeners"` + Options map[string]interface{} `json:"options"` + } + + var mounts []mountData + + for _, mount := range q.mounts { + mounts = append(mounts, mountData{ + Path: strings.TrimSuffix(request.URL.Path, "mounts") + mount.Mount, + MimeType: mount.MimeType, + SampleRate: mount.SampleRate, + FormatDescription: mount.FormatDescription, + Channels: mount.Channels, + Listeners: len(mount.listeners), + Options: mount.Options, + }) + } + + jsonBytes, _ := json.MarshalIndent(mounts, "", " ") + + writer.WriteHeader(http.StatusOK) + writer.Write(jsonBytes) + return + } + for _, mount := range q.mounts { if strings.HasSuffix(request.URL.Path, mount.Mount) { writer.Header().Set("Server", "MeteorLight/radio") @@ -510,7 +554,7 @@ func (q *Queue) HandleRadioRequest(writer http.ResponseWriter, request *http.Req } writer.Header().Set("icy-br", fmt.Sprintf("%d", bitrate)) writer.Header().Set("icy-sr", fmt.Sprintf("%d", mount.SampleRate)) - writer.Header().Set("icy-audio-info", fmt.Sprintf("ice-channels=%d;ice-samplerate=%d;ice-bitrate=%d", 2, mount.SampleRate, bitrate)) + writer.Header().Set("icy-audio-info", fmt.Sprintf("ice-channels=%d;ice-samplerate=%d;ice-bitrate=%d", mount.Channels, mount.SampleRate, bitrate)) if q.config.Radio.Private { writer.Header().Set("icy-pub", "0") writer.Header().Set("icy-do-not-index", "1") @@ -614,7 +658,7 @@ func (q *Queue) HandleRadioRequest(writer http.ResponseWriter, request *http.Req headerBytes := new(bytes.Buffer) - binary.Write(headerBytes, binary.LittleEndian, int64(2)) + binary.Write(headerBytes, binary.LittleEndian, int64(mount.Channels)) binary.Write(headerBytes, binary.LittleEndian, int64(mount.SampleRate)) binary.Write(headerBytes, binary.LittleEndian, int32(len(mount.MimeType))) headerBytes.Write([]byte(mount.MimeType))