|
| 1 | +--- |
| 2 | +title: Advanced Audio Playing |
| 3 | +--- |
| 4 | + |
| 5 | +# About |
| 6 | + |
| 7 | +Pycord provides built-in audio sources for common use cases, but complex bots often require greater |
| 8 | +control over audio streams or need to handle voice playback across hundreds of servers simultaneously. |
| 9 | +This guide covers advanced audio concepts, such as writing custom audio source implementations |
| 10 | +and offloading voice processing to standalone audio nodes, using Lavalink. |
| 11 | + |
| 12 | +## Starting out |
| 13 | + |
| 14 | +[Playing Audio in Voice Channels](./playing.md) has covered Pycord-provided standard audio sources. While |
| 15 | +these built-in classes cover most general needs, [`discord.AudioSource`](https://docs.pycord.dev/en/stable/api/voice.html#discord.AudioSource) |
| 16 | +can be subclassed to create custom audio pipelines, synthesizers, or memory stream handlers. |
| 17 | + |
| 18 | +However, as a bot grows larger, handling multiple local FFmpeg processes and real-time encoding on the |
| 19 | +same machine your bot is running on can quickly bottleneck CPU performance. To solve this, advanced |
| 20 | +bot architectures decouple playback entirely by relying on external audio servers such as Lavalink and |
| 21 | +interacting with them through lightweight client libraries like SonoLink. |
| 22 | + |
| 23 | +### Custom `AudioSource`s |
| 24 | + |
| 25 | +All audio sources covered in the previous guide derive from `discord.AudioSource`, and implement |
| 26 | +at least a custom `read` method. |
| 27 | + |
| 28 | +This method should return 20ms worth of audio, following the 16-bit 48kHz stereo PCM requirement. |
| 29 | +You may override `is_opus` when the audio returned by the `read` method is already Opus encoded. |
| 30 | + |
| 31 | +When no more audio is available, you can return an empty bytes-like object to tell the library the |
| 32 | +audio reading is over. |
| 33 | + |
| 34 | +You may also override `cleanup` when extra clean-up processes are needed if the reading is complete. |
| 35 | + |
| 36 | +A common use case for subclassing an `AudioSource` is when you want to implement an audio player that extracts audio from an online source, such as YouTube (using `youtube_dl`/`yt_dlp`). |
| 37 | + |
| 38 | +```py title="Implementing a custom audio source" |
| 39 | +import asyncio |
| 40 | + |
| 41 | +import discord |
| 42 | +import youtube_dl # third-party library that requires installing |
| 43 | + |
| 44 | +ytdl = youtube_dl.YoutubeDL() |
| 45 | + |
| 46 | + |
| 47 | +# we will implement a basic YTDLSource |
| 48 | +class YTDLSource(discord.AudioSource): |
| 49 | + # we will take a processed audio source, to simplify our handling |
| 50 | + def __init__(self, processed_source: discord.AudioSource, *, data: dict) -> None: |
| 51 | + self.processed_source: discord.AudioSource = processed_source |
| 52 | + self.data: dict = data |
| 53 | + |
| 54 | + self.title = data.get("title") |
| 55 | + self.url = data.get("url") |
| 56 | + |
| 57 | + # reading from this source is essentially reading from the processed source |
| 58 | + def read(self) -> bytes: |
| 59 | + return self.processed_source.read() |
| 60 | + |
| 61 | + def is_opus(self) -> bool: |
| 62 | + return self.processed_source.is_opus() |
| 63 | + |
| 64 | + def cleanup(self) -> None: |
| 65 | + self.processed_source.cleanup() |
| 66 | + |
| 67 | + @classmethod |
| 68 | + async def from_url(cls, url: str) -> YTDLSource: |
| 69 | + # here, we will implement our logic for obtaining the youtube audio from an url |
| 70 | + # we will be using asyncio.to_thread, because extracting the info is a sync operation |
| 71 | + # and may block our bot for the duration it takes to finish |
| 72 | + data = await asyncio.to_thread(ytdl.extract_info, url, download=True) |
| 73 | + |
| 74 | + # playlists have an "entries" key, so grab the first item |
| 75 | + if "entries" in data: |
| 76 | + data = data["entries"][0] |
| 77 | + |
| 78 | + filename = ytdl.prepare_filename(data) |
| 79 | + return cls(discord.FFmpegPCMAudio(filename), data=data) |
| 80 | +``` |
| 81 | + |
| 82 | +For a more advanced explanation and implementation of this source, check |
| 83 | +[the `basic_voice.py` example](https://github.com/Pycord-Development/pycord/blob/master/examples/basic_voice.py). |
| 84 | + |
| 85 | +### Using Lavalink |
| 86 | + |
| 87 | +Lavalink is the ultimate audio playback server tool, as it can handle both remote and local audio |
| 88 | +playing while being easy to handle and set up, and low-usage. |
| 89 | + |
| 90 | +First, you need to run a [Lavalink Server](https://github.com/lavalink-devs/Lavalink) to connect with. |
| 91 | +In case you do not know how, there are multiple documentation to do so that will not be covered here, |
| 92 | +but we recommend you the [SonoLink Lavalink Setup Guide](https://sonolink.readthedocs.io/en/latest/guides/lavalink-setup.html). |
| 93 | + |
| 94 | +To interact with your Lavalink server you must send HTTP requests, but here we will be using |
| 95 | +[SonoLink](https://github.com/sonolink/sonolink), an API wrapper for Lavalink. |
| 96 | + |
| 97 | +To install it, you can simply run: |
| 98 | + |
| 99 | +```sh title="Installing sonolink" |
| 100 | +pip install -U sonolink |
| 101 | +``` |
| 102 | + |
| 103 | +Now, you will need to connect to your Lavalink server using a Node: |
| 104 | + |
| 105 | +```py title="Connecting to Lavalink" |
| 106 | +import discord |
| 107 | +import sonolink |
| 108 | + |
| 109 | +bot = discord.Bot() |
| 110 | +sl_client = sonolink.Client(bot) |
| 111 | + |
| 112 | +sl_client.create_node( |
| 113 | + id="main-node", # the unique ID for the node being created |
| 114 | + uri="http://0.0.0.0:2333", # HTTP(S) protocol is required when passing URI |
| 115 | + password="youshallnotpass" |
| 116 | +) |
| 117 | + |
| 118 | +@bot.listen() |
| 119 | +async def on_connect() -> None: |
| 120 | + await sl_client.start() # starts connection to the created nodes |
| 121 | + print("SonoLink nodes connected successfully!") |
| 122 | +``` |
| 123 | + |
| 124 | +<br /> |
| 125 | + |
| 126 | +Now you are finished making your node! Next, you will want to: |
| 127 | + |
| 128 | +1. Make a `play` command |
| 129 | +1. Add connection-handling events |
| 130 | + |
| 131 | +#### Making a `play` command |
| 132 | + |
| 133 | +The core functionality for a music bot using SonoLink is a `play` command, as it allows users to reproduce |
| 134 | +their own songs as they please. |
| 135 | + |
| 136 | +To do this, you need to create a command that ensures a `sonolink.Player` instance is connected |
| 137 | +and available to play audio. |
| 138 | + |
| 139 | +```py title="Creating a play command" |
| 140 | +@bot.slash_command() |
| 141 | +async def play(ctx: discord.ApplicationContext, *, search: str) -> None: |
| 142 | + # Before proceeding with the logic, we must check the ctx.author is |
| 143 | + # connected in a voice channel |
| 144 | + if not ctx.author.voice or not ctx.author.voice.channel: |
| 145 | + await ctx.respond("You must be in a voice channel first!") |
| 146 | + return |
| 147 | + |
| 148 | + # We need to check we are connected to a voice channel AND the |
| 149 | + # voice client connected is a `sonolink.Player` instance. |
| 150 | + vc = ctx.voice_client |
| 151 | + |
| 152 | + # Connect to the voice channel if we are not yet |
| 153 | + if not vc: |
| 154 | + vc = await ctx.author.voice.channel.connect( |
| 155 | + cls=sonolink.Player, |
| 156 | + ) |
| 157 | + elif not isinstance(vc, sonolink.Player): |
| 158 | + # And here, if there is a voice client connected, check it is |
| 159 | + # a sonolink.Player instance. |
| 160 | + # If not, reconnect with a sonolink.Player instance |
| 161 | + await vc.disconnect(force=True) |
| 162 | + vc = await ctx.author.voice.channel.connect( |
| 163 | + cls=sonolink.Player, |
| 164 | + ) |
| 165 | + |
| 166 | + # This check is not required, but creates a better experience for final users |
| 167 | + # We will check that the channel the Player is connected is the same the ctx.author |
| 168 | + # is in. |
| 169 | + if ctx.author.voice.channel.id != vc.channel.id: |
| 170 | + await ctx.respond("You must be in the same voice channel as the bot.") |
| 171 | + return |
| 172 | + |
| 173 | + # We will now search for the song the user provided in the `search` parameter |
| 174 | + # We can optionally pass a `source` keyword argument to reduce the locations |
| 175 | + # the song will be search from. |
| 176 | + result = await sl_client.search_track(search) |
| 177 | + |
| 178 | + # SonoLink returns a SearchResult instance when searching tracks |
| 179 | + # so we will need to check that the result is not empty and is not an error |
| 180 | + if result.is_empty() or result.is_error() or not result.result: |
| 181 | + await ctx.respond("Song not found!") |
| 182 | + return |
| 183 | + |
| 184 | + # The fetched result can be a list of tracks, a playlist or a single track |
| 185 | + # so we must check against all those possibilities |
| 186 | + if isinstance(result.result, list): |
| 187 | + track = result.result[0] |
| 188 | + elif isinstance(result.result, sonolink.models.Playlist): |
| 189 | + track = result.result.tracks[0] |
| 190 | + else: |
| 191 | + track = result.result |
| 192 | + |
| 193 | + # And finally... we play the track we obtained |
| 194 | + await vc.play(track) |
| 195 | + await ctx.respond(f"Now playing: `{track.title}`") |
| 196 | +``` |
| 197 | + |
| 198 | +<br /> |
| 199 | + |
| 200 | +Now that this is done, the only thing left to do is make your connect events. |
| 201 | + |
| 202 | +#### Adding connect events |
| 203 | + |
| 204 | +The final step of this guide is connecting to the node to your server when the bot goes online. |
| 205 | + |
| 206 | +To make it, you will want to do the following: |
| 207 | + |
| 208 | +```py title="Adding connect events" |
| 209 | +@bot.event |
| 210 | +async def on_sonolink_node_ready(event: sonolink.gateway.ReadyEvent) -> None: |
| 211 | + print(f"Node with ID {event.node.id!r} has connected!") |
| 212 | + print(f"Resumed session: {event.resumed}") |
| 213 | + |
| 214 | +bot.run("token") |
| 215 | +``` |
| 216 | + |
| 217 | +Congratulations! You are now able to create custom audio sources for advanced playback, and to offload |
| 218 | +the playback to an external node using Lavalink and interacting with it using SonoLink! Most bots and |
| 219 | +Discord API wrappers don't have this as a feature, so this is quite an accomplishment. Thankfully, |
| 220 | +Pycord makes it easy to make complex bots so that you can get the most advanced of ideas down. |
| 221 | + |
| 222 | +!!! info "Related Topics" |
| 223 | + |
| 224 | + - [Rules and Common Practices](../getting-started/rules-and-common-practices.md) |
0 commit comments