Documentation
¶
Overview ¶
Package lullaby plays music in Discord voice channels for bots built on disgo: songs one after another in each server, without a gap between them. It finds songs on YouTube, YouTube Music, SoundCloud and the music services, has ffmpeg encode them to Opus, and sends them through disgo's voice package, encrypted with dave-go. Package songs has the Song type and the errors songs fail with. How the music shows, like a bot's song cards, is up to the app, which Events tells what happens.
Times in the comments were measured on a Raspberry Pi 5 with 8 GB, "the Pi", on a home connection.
Example ¶
A bot sets disgo up with DAVE's sessions and voice states in its cache, makes the engine, and plays songs in the voice channel of whoever asks. examples/bot in the repository is a whole bot.
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/disgoorg/disgo"
"github.com/disgoorg/disgo/bot"
"github.com/disgoorg/disgo/cache"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/gateway"
"github.com/disgoorg/disgo/rest"
"github.com/disgoorg/disgo/voice"
"github.com/disgoorg/snowflake/v2"
"github.com/windrunner21/lullaby"
)
func main() {
ctx := context.Background()
dave := lullaby.NewDAVE()
client, err := disgo.New(os.Getenv("DISCORD_TOKEN"),
bot.WithGatewayConfigOpts(gateway.WithIntents(gateway.IntentGuilds, gateway.IntentGuildVoiceStates)),
bot.WithCacheConfigOpts(cache.WithCaches(cache.FlagVoiceStates)),
bot.WithVoiceManagerConfigOpts(voice.WithDaveSessionCreateFunc(dave.Create)),
// Song titles come from strangers: one saying "@everyone" pings nobody.
bot.WithRestConfigOpts(rest.WithDefaultAllowedMentions(discord.AllowedMentions{
Parse: []discord.AllowedMentionType{}, Roles: []snowflake.ID{}, Users: []snowflake.ID{},
})),
)
if err != nil {
log.Fatal(err)
}
music, err := lullaby.New(lullaby.Config{Voice: client.VoiceManager, DAVE: dave})
if err != nil {
log.Fatal(err)
}
music.Start(ctx) // warms up the connections, so the first song doesn't wait for them
// In a /play handler, with the guild and the asker's voice channel:
var guildID, voiceChannelID snowflake.ID
track, place, err := music.Play(ctx, lullaby.Request{GuildID: guildID, ChannelID: voiceChannelID, Query: "snooze sza"})
switch {
case err != nil:
fmt.Println("couldn't play it:", err)
case place > 0:
fmt.Printf("%s is number %d in line\n", track.Song().Title, place)
default:
fmt.Println("playing", track.Song().Title)
}
}
Output:
Index ¶
- Constants
- Variables
- func NewClient() *http.Client
- type Config
- type DAVE
- type End
- type Engine
- func (e *Engine) Channel(guildID snowflake.ID) (snowflake.ID, bool)
- func (e *Engine) Disconnected(guildID snowflake.ID) (*Track, bool)
- func (e *Engine) GetReady()
- func (e *Engine) JoinEarly(guildID, channelID snowflake.ID)
- func (e *Engine) Moved(guildID, channelID snowflake.ID)
- func (e *Engine) Pause(guildID snowflake.ID, t *Track) (paused, ok bool)
- func (e *Engine) Play(ctx context.Context, r Request) (*Track, int, error)
- func (e *Engine) Playing(guildID snowflake.ID) (*Track, bool)
- func (e *Engine) Skip(guildID, by snowflake.ID) bool
- func (e *Engine) Start(ctx context.Context) error
- func (e *Engine) Stop(guildID snowflake.ID) (*Track, bool)
- func (e *Engine) StopSong(guildID snowflake.ID, t *Track) bool
- func (e *Engine) Suggest(ctx context.Context, query string) []songs.Song
- func (e *Engine) UpNext(guildID snowflake.ID) (songs.Song, int, bool)
- type Events
- type Request
- type Track
Examples ¶
Constants ¶
const ( // MaxSuggestions is how many songs Suggest suggests at most. MaxSuggestions = 10 // MaxQueue is how many songs can wait in a server's queue. MaxQueue = 100 )
Variables ¶
var ( // ErrNotFound is returned by Play when a search finds no song. ErrNotFound = errors.New("no song found") // ErrJoin is returned by Play when joining the voice channel failed. ErrJoin = errors.New("couldn't join voice") // ErrFull is returned by Play when the queue has MaxQueue songs. ErrFull = errors.New("the queue is full") // ErrStopped is returned by Play when the music was stopped before the // song could start. ErrStopped = errors.New("the music stopped") )
Functions ¶
func NewClient ¶
NewClient returns an HTTP client made for the engine, which the app can share. It's a copy of http.DefaultTransport, TLS settings included, that keeps connections open between songs and resumes TLS sessions with the audio servers. If the app has replaced http.DefaultTransport with a transport of its own, say for tracing, the client uses that as it is.
Types ¶
type Config ¶
type Config struct {
// Voice is disgo's voice manager, set up with DAVE's sessions:
// bot.WithVoiceManagerConfigOpts(voice.WithDaveSessionCreateFunc(dave.Create)).
Voice voice.Manager
// DAVE keeps the voice connections' encryption sessions, which the
// engine waits on so the start of a song isn't lost.
DAVE *DAVE
// Events hears what happens to the music. Nil hears nothing.
Events Events
// HTTP asks the sites for songs and downloads them. Nil is NewClient's.
// Unless AllowPrivateLinks, the engine uses a copy: an *http.Transport is
// copied, with connections of its own and the engine's dialer in place of
// its dial hooks; another kind of transport has each request's host
// looked up and checked before it's sent. yt-dlp, and ffmpeg reading a
// playlist it was left, do their own networking.
HTTP *http.Client
// Logger is where the engine logs. Nil is slog.Default().
Logger *slog.Logger
// FFmpeg is ffmpeg's path, or its name on PATH, which encodes every
// song. Empty is "ffmpeg". New fails when it can't find it.
FFmpeg string
// YTDLP is yt-dlp's path or name: the backup for YouTube and SoundCloud,
// and the way to every other site. Empty is without it.
YTDLP string
// Bun is Bun's path or name, which yt-dlp runs YouTube's JavaScript
// with. Empty leaves it to yt-dlp.
Bun string
// Cache is where yt-dlp keeps what it learned about YouTube's JavaScript.
// Empty is yt-dlp's own.
Cache string
// Cookies is a cookies.txt file from a spare YouTube account, which
// yt-dlp signs in with during a bot check, so it needs YTDLP too. Empty
// is never signing in.
Cookies string
// AllowPrivateLinks lets songs come from the bot host's own network,
// like a music server at home. Off, links to private addresses fail with
// songs.ErrUnplayable, so a stranger's link can't make the bot fetch its
// owner's router or a cloud's metadata service. Through an
// *http.Transport, the engine's requests are checked as they connect,
// redirects included; through another kind of transport, each request's
// host is checked first, which can't promise where it then connects.
// Links handed to yt-dlp, or to ffmpeg to read itself, are checked
// first, but those programs resolve and follow redirects on their own.
AllowPrivateLinks bool
}
Config is what an Engine works with. Voice and DAVE are needed; the rest have defaults.
type DAVE ¶
type DAVE struct {
// contains filtered or unexported fields
}
DAVE keeps the end-to-end encryption session of each server's voice connection, which Discord has required since 2026-03-01. Songs wait while a session is being set up, so their start isn't sent unencrypted and dropped.
type End ¶
type End struct {
// SkippedBy is who skipped it, if anyone.
SkippedBy snowflake.ID
// Err says why it broke off, if it did.
Err error
}
End is how a song came to be over.
type Engine ¶
type Engine struct {
// contains filtered or unexported fields
}
Engine plays music in any number of servers.
func New ¶
New returns an engine. It fails without Config.Voice or Config.DAVE, or when it can't find ffmpeg, or yt-dlp or Bun when they're given.
func (*Engine) Channel ¶
Channel returns the voice channel the player is in, or on its way to, in a server.
func (*Engine) Disconnected ¶
Disconnected stops the music in a server after someone took the player out of voice. It returns the song that was playing, if any, and whether it stopped: a player still joining is left to finish.
func (*Engine) GetReady ¶
func (e *Engine) GetReady()
GetReady gets the rest ready for someone typing a song's name, beside joining voice: an ffmpeg for their song, and a connection to YouTube's API, which Go closes when it's been unused for a while.
func (*Engine) JoinEarly ¶
JoinEarly starts joining a voice channel for someone typing a song's name, so the player is there by the time they pick one. Joining took about 1.1 s, which came before the first song's first sound. If no song comes, it leaves after 30 seconds. It does nothing while the player is in voice in the server already.
func (*Engine) Pause ¶
Pause pauses t, or lets it go on if it's paused, and reports whether it's paused now. ok is false when t isn't playing anymore.
func (*Engine) Play ¶
Play plays a song in a voice channel, or puts it in the queue when one is playing already. It returns the song and its place in the queue, or 0 when it plays now. It joins the channel, finds the song and starts ffmpeg all at once. ctx only bounds the wait: once Play returns, the song plays on until it's over or the engine stops, and a lookup or join given up on goes on for whoever asks next.
func (*Engine) Skip ¶
Skip ends the song playing in a server, and the next one takes over. It reports whether a song was playing.
func (*Engine) Start ¶
Start warms up the connections to YouTube and finds SoundCloud's API key, so the first song doesn't wait for them. ctx is the engine's lifetime: once it's done, the engine stops the music in every server and leaves voice, without telling Events, and Play fails with ErrStopped. Start can be called once; an engine that's never started lives until the program ends.
func (*Engine) Stop ¶
Stop stops the music in a server and leaves voice, in the background. It returns the song that was playing, if any, and whether the player was in voice there, or on its way.
func (*Engine) StopSong ¶
StopSong is Stop, but only while t is playing, so an old song's buttons can't stop a newer one. It reports whether it stopped.
func (*Engine) Suggest ¶
Suggest finds songs for someone typing a song's name, as fast as it can: from memory, from an earlier search narrowed down to the words typed since, or from YouTube Music. A link gets no suggestions; it's looked up while they're still choosing instead, so Play finds it ready. What's suggested is remembered, since picking a suggestion only sends its link.
Example ¶
Autocomplete suggests songs while someone types, and gets ready for the one they'll pick: it joins their voice channel, warms up YouTube and starts an ffmpeg, so the song starts as soon as they press Enter.
package main
import (
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/handler"
"github.com/disgoorg/snowflake/v2"
"github.com/windrunner21/lullaby"
)
func main() {
var music *lullaby.Engine
autocomplete := func(e *handler.AutocompleteEvent, voiceChannelID snowflake.ID) error {
music.JoinEarly(*e.GuildID(), voiceChannelID)
music.GetReady()
choices := []discord.AutocompleteChoice{}
for _, s := range music.Suggest(e.Ctx, e.Data.String("song")) {
// The value is the song's link, which Play plays at once, with
// YouTube Music's title and artist.
choices = append(choices, discord.AutocompleteChoiceString{Name: s.Title + " · " + s.Artist, Value: s.Link})
}
return e.AutocompleteResult(choices)
}
_ = autocomplete
}
Output:
type Events ¶
type Events interface {
// Started is called when a song from the queue starts playing by itself,
// after the one before it. A song Play starts at once isn't told about.
Started(guildID snowflake.ID, t *Track)
// Ended is called once a song that played is over by itself: it ran
// out, broke off or was skipped. A song stopped isn't told about: Stop
// and Disconnected return the song that was playing, and StopSong's
// caller has it already. Nor is the end of the engine's lifetime.
Ended(guildID snowflake.ID, t *Track, end End)
// Left is called when the engine leaves voice by itself, because nothing
// played, or a song stayed paused, for a while. playing is the song that
// was playing, or nil.
Left(guildID snowflake.ID, playing *Track)
// QueueChanged is called when the queue changes by itself: a song that
// won't start is passed over.
QueueChanged(guildID snowflake.ID)
}
Events hears what happens to the music, so the app can show it. The engine never calls them while it holds its own lock, so they may call the engine, and the app may call the engine while holding a lock its events take. They can be called at the same time from different goroutines, for different servers or for one, so they must be safe for that.
Example ¶
Events hears what happens to the music. A song's own data, like where it was asked for, rides along as Request.Meta and comes back as Track.Meta.
package main
import (
"context"
"github.com/disgoorg/disgo/bot"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/snowflake/v2"
"github.com/windrunner21/lullaby"
)
// cards posts a message when a song from the queue starts, in the text
// channel it was asked for in, which it keeps as the Track's Meta. The
// client is set up to ping nobody, as in Example.
type cards struct {
client *bot.Client
}
func (c cards) Started(_ snowflake.ID, t *lullaby.Track) {
channelID := t.Meta.(snowflake.ID)
c.client.Rest.CreateMessage(channelID, discord.MessageCreate{Content: "Now playing " + t.Song().Title})
}
func (cards) Ended(snowflake.ID, *lullaby.Track, lullaby.End) {}
func (cards) Left(snowflake.ID, *lullaby.Track) {}
func (cards) QueueChanged(snowflake.ID) {}
func main() {
// With a client and DAVE set up as in Example:
newMusic := func(client *bot.Client, dave *lullaby.DAVE) (*lullaby.Engine, error) {
return lullaby.New(lullaby.Config{Voice: client.VoiceManager, DAVE: dave, Events: cards{client: client}})
}
// In /play, the text channel it was asked in rides along as Meta:
play := func(ctx context.Context, music *lullaby.Engine, guildID, voiceChannelID, textChannelID snowflake.ID) error {
_, _, err := music.Play(ctx, lullaby.Request{GuildID: guildID, ChannelID: voiceChannelID, Query: "snooze sza", Meta: textChannelID})
return err
}
_, _ = newMusic, play
}
Output:
type Request ¶
type Request struct {
// GuildID is the server to play it in.
GuildID snowflake.ID
// ChannelID is the voice channel to play it in. If the engine is in voice
// in the server already, it stays in its channel: the app decides who may
// ask for songs there.
ChannelID snowflake.ID
// Query is a song's name, or a link.
Query string
// Next puts it at the front of the queue.
Next bool
// Meta is the Track's.
Meta any
// Asked is when it was asked for, which the log counts from. It may be
// zero.
Asked time.Time
}
Request is a song asked for.
type Track ¶
type Track struct {
// Meta is the app's own, like who asked for the song and where, from
// Request.Meta. The engine never looks at it; an app that changes it
// while others read it keeps it safe itself.
Meta any
// contains filtered or unexported fields
}
Track is a song in a server, playing or waiting its turn.
func (*Track) Cover ¶
Cover is the best picture for the song. A YouTube video's own is 4:3 with black bars, so a better one is found while the song starts, and waited for here if it isn't in yet, for up to a second.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
voicebench
command
Command voicebench compares lullaby with Lavalink the way Discord would hear them: both connect to a stand-in Discord voice server on the same machine, which notes when each one's audio arrives, how evenly it comes, and how much memory and CPU each engine takes.
|
Command voicebench compares lullaby with Lavalink the way Discord would hear them: both connect to a stand-in Discord voice server on the same machine, which notes when each one's audio arrives, how evenly it comes, and how much memory and CPU each engine takes. |
|
examples
|
|
|
bot
command
Command bot is a small music bot built on lullaby: /play a song's name or link, with suggestions while you type, /skip and /stop.
|
Command bot is a small music bot built on lullaby: /play a song's name or link, with suggestions while you type, /skip and /stop. |
|
internal
|
|
|
audio
Package audio turns a song's audio into the 20 ms Opus frames Discord plays: it downloads the audio in pieces, has ffmpeg encode it, and hands the frames out without ever making Discord wait.
|
Package audio turns a song's audio into the 20 ms Opus frames Discord plays: it downloads the audio in pieces, has ffmpeg encode it, and hands the frames out without ever making Discord wait. |
|
netguard
Package netguard keeps lullaby off the bot host's own network.
|
Package netguard keeps lullaby off the bot host's own network. |
|
redact
Package redact hides what could give access in links before they're logged or handed back: query values, which can hold tokens like a private SoundCloud song's secret_token or a music server's API key, and passwords.
|
Package redact hides what could give access in links before they're logged or handed back: query values, which can hold tokens like a private SoundCloud song's secret_token or a music server's API key, and passwords. |
|
sources
Package sources finds songs and their audio for lullaby: YouTube asked as its iPhone app does, YouTube Music's search, SoundCloud, yt-dlp as the backup, and Spotify, Apple Music, Deezer and Yandex Music songs matched on YouTube Music by name.
|
Package sources finds songs and their audio for lullaby: YouTube asked as its iPhone app does, YouTube Music's search, SoundCloud, yt-dlp as the backup, and Spotify, Apple Music, Deezer and Yandex Music songs matched on YouTube Music by name. |
|
Package songs is what lullaby says about songs: a Song and its Audio, the errors a song can't be played with, and helpers for YouTube and SoundCloud links.
|
Package songs is what lullaby says about songs: a Song and its Audio, the errors a song can't be played with, and helpers for YouTube and SoundCloud links. |