melodix

music

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).

How it works (high level)

At runtime the system is a pipeline:

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

Install

go get github.com/keshon/melodix/pkg/music/...

Quick start

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.

Algorithms (by stage)

1) Resolve (input → TrackInfo)

Goal: convert user input into canonical metadata + a parser preference list.

The resolver is intentionally pluggable; the player does not care how a track was discovered, only that it has a URL + parsers list.

2) Enqueue (TrackInfo → queue)

Goal: turn TrackInfo into parsers.TrackParse and append to the FIFO queue.

3) Start playback (dequeue → open resilient stream)

Performed by Player.PlayNext():

4) Open stream (choose parser)

Performed inside RecoveryStream.Open(seek):

5) Media recovery (parser/ffmpeg level)

Recovery 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:

This 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:

If duration is unknown, early-EOF recovery is only attempted at the beginning (firstRead or seekSec < 1.0).

6) Sink streaming + voice transport recovery

The sink drives the read loop via AudioSink.Stream(reader, stopCh):

Discord / UI: errors and PlayerStatus

Player.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

  1. Broken or geo-blocked URL three /play commands in a row — no “wrong” error attributed to a later play; guild message shows failure when playback dies after start.
  2. Enqueue while something is playing — queue / status messages stay consistent.
  3. /next onto a broken next track — error text matches other failure paths (same length cap / phrasing family).

Key extension points

Requirements

Documentation

License

music is licensed under the MIT License.