Queue-based music playback library for Go with pluggable audio sinks and track resolvers. Resolves URLs and search queries (YouTube, SoundCloud, radio), opens PCM streams via multiple parsers (yt-dlp, kkdai, ffmpeg), and plays through a sink of your choice (e.g. speaker or custom Discord voice).
At runtime the system is a pipeline:
sources.TrackInfo (URL, title, available parsers)s16le @ 48kHz stereo)flowchart TD
A["User input<br/>URL / search"] --> B["Resolver<br/>Resolve()"]
B --> C["TrackInfo + AvailableParsers"]
C --> D["Player.Enqueue()"]
D --> E["Player.PlayNext()"]
E --> F["RecoveryStream.Open()"]
F --> G{"Open ok?"}
G -- no --> F
G -- yes --> H["Sink.Stream(rs)"]
H --> I{"Read error?"}
I -- no --> H
I -- io.EOF early --> J["RecoveryStream.reopen()"]
J --> F
I -- instant fail (first read) --> K["Advance parserIndex"]
K --> F
I -- voice transport error --> L["ReopenAfterTransportFailure()"]
L --> F
I -- other error --> M["Stop track + PlayNext()"]
M --> E
H --> N["Track ended"]
N --> M
go get github.com/keshon/melodix/pkg/music/...
Create a sink provider (e.g. speaker for local playback), a resolver, and a player; then enqueue and play:
provider := sink.NewSpeakerProvider()
defer provider.Close()
res := resolve.New()
p := player.New(provider, res)
// Enqueue a URL or search query, then start playback
_ = p.Enqueue("https://www.youtube.com/watch?v=...", "", "")
_ = p.PlayNext("") // "" for local; use voice channel ID for Discord
Listen to p.PlayerStatus for status updates (Playing, Added, Stopped, Error). See examples/clispeaker for a full runnable CLI.
Goal: convert user input into canonical metadata + a parser preference list.
source/parser hints.[]sources.TrackInfo where TrackInfo.AvailableParsers is ordered by preference.The resolver is intentionally pluggable; the player does not care how a track was discovered, only that it has a URL + parsers list.
Goal: turn TrackInfo into parsers.TrackParse and append to the FIFO queue.
AvailableParsers are rejected/skipped.CurrentParser starts as the first entry in AvailableParsers (will be updated later by recovery/open logic).Performed by Player.PlayNext():
stream.RecoveryStream(track) and call rs.Open(seek=0).Performed inside RecoveryStream.Open(seek):
parserIndex, iterate through track.SourceInfo.AvailableParsers.retries[parser] >= maxRecoveryAttempts → skipopenWithParser(track, parser, seek)parserIndex to that parser’s indextrack.CurrentParser = parserfirstRead = trueRecovery is intentionally conservative to avoid false-positive “fallback” when a track naturally ends.
A) Instant failure right after open
If the very first Read() on the opened stream returns any error (including an EOF-like failure from ffmpeg), it is treated as an instant fail:
parserIndex++seekSec using the next parserThis is designed for cases like “ffmpeg opened, then immediately 403/forbidden and closed stdout”.
B) Early EOF (mid-track)
If Read() returns io.EOF with n==0 and the track is far from its expected duration, recovery attempts to reopen:
seekSecmaxRecoveryAttempts per parserIf duration is unknown, early-EOF recovery is only attempted at the beginning (firstRead or seekSec < 1.0).
The sink drives the read loop via AudioSink.Stream(reader, stopCh):
stream.ErrVoiceTransport (Discord transport issues):
rs.ReopenAfterTransportFailure() to reopen media at the current seekPlayerStatusPlayer.PlayerStatus is a buffered channel meant for a single long-lived consumer per player (competing receivers steal events). The Discord voice service runs one status watcher per guild player for async transitions (auto-advance to the next track, natural queue end); slash handlers render interaction-driven outcomes (“Now Playing” / “Track(s) Added”) synchronously since PlayNext/enqueue results are known in the handler. The player stores a capped lastPlaybackUserErr for consistent embed text.
When wired to Discord, the voice service passes Options.OnPlaybackFailed at player construction so a failure after “Now Playing” can edit the guild status message (same message id as “Now Playing”) instead of relying on an interaction follow-up that already finished.
The ffmpeg and kkdai parser packages use package-level loggers: call ffmpeg.SetLogger(appLogger) and kkdai.SetLogger(appLogger) once at process startup (the Discord bot does this in NewBot). All parsers build their ffmpeg invocation via ffmpeg.NewPCMCommand, which captures ffmpeg stderr for every parser: lines that look like HTTP 403 / forbidden / conversion failures are logged at Warn, other lines at Debug to limit noise. The binary paths default to ffmpeg / yt-dlp on PATH and can be overridden via ffmpeg.FFmpegPath / ytdlp.YtdlpPath.
Manual regression checklist
/play commands in a row — no “wrong” error attributed to a later play; guild message shows failure when playback dies after start./next onto a broken next track — error text matches other failure paths (same length cap / phrasing family).player.Resolver to support new sources or search.sink.AudioSink / sink.Provider to support new outputs.parsers.Streamer and register it in stream.Registry.PATH. Used by most parsers to decode audio to PCM.sink.NewSpeakerProvider()) uses oto for audio output. Omit the speaker sink if you only need a custom sink (e.g. Discord).music is licensed under the MIT License.