Upgrading Mirai's Music Module

Mon, 23 Oct 2017 05:00:00 UTC

mirai, discord, music, lavalink, development

Ever since late 2016 Mirai has been streaming Listen.moe to voice channels. Since Mirai didn't make enough money to afford voice servers, we needed a way to do this with minimal CPU and memory impact. The solution: Eris's SharedStream module. With this we could send the stream to as many channels as we wanted with only one ffmpeg process.

SharedStream required only a few lines of code, and ran in the same process as the bot:

let sharedStream = new (require('eris')).SharedStream();

sharedStream.on('error', error => /* Error handler */);

// Add connection
bot.joinVoiceChannel(channel.id, { shared: true }).then(connection => {
    sharedStream.add(connection);

    if (!sharedStream.playing)
        sharedStream.play('https://listen.moe/stream', { inputArgs: ['-user-agent', `Your Bot v${version} (example.com)`] });

    connection.on('error', error => /* Error handler */)
}).catch(e => /* Error handler */);

// Remove connection
bot.leaveVoiceChannel(channel.id);
sharedStream.voiceConnections.delete(channel.id);

The Problem

This was fine back then since Mirai didn't use much CPU, but as more features were added, CPU usage increased. Now we get CPU spikes when certain tasks (such as twitch notifications) run.

CPU Graph
CPU usage spikes blocking voice sending

Everyone listening to the stream would be able to hear the music stuttering or freezing when CPU usage was high. The simple solution would be to reduce CPU usage by staggering resource hungry tasks, but that would only be temporary. Music would have to move to another process. The problem is that SharedStream doesn't work when it's disconnected from the bot. I wasn't going to write my own voice service either.

Our CPU restriction also meant we had to disable volume modification, since it heavily increases CPU usage. Because of this Mirai was way too loud, even at a low volume percent.

The Solution

Recently I was made aware of Lavalink. Lavalink describes itself as a "Standalone audio sending node based on Lavaplayer and JDA-Audio. Allows for sending audio without it ever reaching any of your shards." In it's features it lists a minimal CPU/memory footprint among other appealing items. It's also used in production by other large bots. While this doesn't have everything we want (like shared streams), it was decided to be the best option.

Since the Lavalink nodes will be detached from the process we will also need eris-lavalink. This will handle the connection to nodes and manage voice connections. It can also load balance if we need more nodes in the future.

Implementing

Installing:

# Install Java
sudo apt-get update
sudo apt-get install default-jre

# Get Lavalink
mkdir ~/lavalink
cd lavalink
wget download_url_for_latest_lavalink

# Create Config based on sample config
nano application.yml

# Allow configured ports
sudo ufw allow 2333 # HTTP API
sudo ufw allow 8080 # WS

Running as a service:

# Create run.sh, which will run lavalink
nano run.sh
# Then write this inside
java -jar /home/user/lavalink/Lavalink.X.X.jar

# Make executable
chmod +x run.sh

# Create service
sudo nano /lib/systemd/system/lavalink.service

The service:

[Unit]
Description=LavalinkServer
[Service]
WorkingDirectory=/home/user/lavalink
ExecStart=/home/user/lavalink/run.sh
Type=simple
Restart=always
[Install]
WantedBy=multi-user.target

Starting:

sudo systemctl daemon-reload
sudo systemctl start lavalink
sudo systemctl enable lavalink

Using in Mirai

First we need to setup eris-lavalink (briantanner/eris-lavalink).

In the following code, a config object will be referenced. The object should look similar to this:

{
    "lavalink": {
        "nodes": [
            { "host": "127.0.0.1", "port": 8080, "region": "us", "password": "youshallnotpass" }
        ],
        "regions": {
            "eu": ["eu", "amsterdam", "frankfurt", "russia", "hongkong", "singapore", "sydney"],
            "us": ["us", "brazil"]
        }
    }
}

First we need to replace eris.voiceConnections with eris-lavalink.PlayerManager:

mirai.once('ready', () => {
    const { PlayerManager } = require('eris-lavalink');

    if (!(mirai.voiceConnections instanceof PlayerManager)) {
        mirai.voiceConnections = new PlayerManager(mirai, config.lavalink.nodes, {
            numShards: config.eris.maxShards,
            userId: mirai.user.id,
            regions: config.lavalink.regions,
            defaultRegion: 'us',
        });
    }
})

Now we'll create two helper functions to use with music commands

async function resolveTracks(node, search) {
    let result = await axios.get(`http://${node.host}:2333/loadtracks?identifier=${search}`, {
        headers: {
            'Authorization': node.password,
            'Accept': 'application/json'
        }
    });

    if (!result)
        throw 'Unable play that video.';

    return result.data; // array of tracks resolved from lavalink
}

async function getPlayer(channel, allowCreate = false) {
    if (!channel || !channel.guild)
        throw 'Not a guild channel';

    let player = mirai.voiceConnections.get(channel.guild.id);
    if (player)
        return player;

    if (!allowCreate) // Stop if not joining voice
        return null;

    let options = { };
    // enable this if you have nodes for each region
    // if (channel.guild.region)
    //  options.region = channel.guild.region;

    return await mirai.voiceConnections.join(channel.guild.id, channel.id, options);
}

resolveTracks will take user-inputted URLs and return a base64 string for Lavaplayer and metadata for the track.
getPlayer will allow us to get the current voice player, and create new ones if one doesn't already exist.

By setting the region in options, eris-lavalink will create a player on the node for that region. This allows for minimal latency between Lavalink nodes and Discord's voice servers.

With all of this we can now send audio to voice channels. An example play function is shown below.

async function play(channel, url) {
    try {
        let isNew = !mirai.voiceConnections.has(channel.guild.id),
            player = await getPlayer(channel, true),
            tracks = await resolveTracks(nodes[0], url);

        if (isNew) {
            player.setVolume(20);

            player.on('disconnect', err => {
                if (err)
                    return logger.error('Lavalink plyer disconnected with error:', err);

                return logger.info('Lavalink player disconnected');
            });

            player.on('error', err => logger.error('Lavalink plyer error:', err.toString()));

            player.on('end', data => {
                // REPLACED reason is emitted when playing without stopping
                if (data.reason && data.reason === 'REPLACED')
                    return;

                // Play next in queue
            });
        }

        player.play(tracks[0].track);

        return tracks[0]; // Use this to tell the user "Now playing {title} ({length})"
    } catch (error) {
        logger.warn('Error playing music:', error);
        throw error;
    }
}

But Wait! There's More

This is fine for playing listen.moe, but we can do so much more! Using this we can add a fully-featured music module to Mirai. Currently Lavalink supports the following sources:

What a waste using it for just one http stream! It was decided to open up the module for certain sources. After that planned features kept stacking up: User-made radios/playlists, queue commands, skipping, pausing, seeking, etc. They'll all be making their way into the music module after the beta launches in December.


That's all for now. Hopefully you're excited for the launch of Mirai Music Beta in update 4.11 (Coming Soon™). For questions or extra leeks go to discord.gg/rkWPSdu.