├── www
├── scripts
│ ├── socket.js
│ ├── vendor
│ │ ├── events.once.js
│ │ └── eventemitter3.min.js
│ ├── index.js
│ └── sfx.js
├── sound-effects
│ ├── ding.mp3
│ └── tng_chirp_clean.mp3
├── sfx.html
├── styles
│ └── style.css
└── index.html
├── lib
├── events.js
├── config.js
└── chat-client.js
├── .env.example
├── package.json
├── LICENSE
├── server.js
├── .gitignore
├── README.md
└── config.toml
/www/scripts/socket.js:
--------------------------------------------------------------------------------
1 | /** @type {import("socket.io").Socket} */
2 | const socket = io();
--------------------------------------------------------------------------------
/www/sound-effects/ding.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlcaDesign/sound-trigger-bot/HEAD/www/sound-effects/ding.mp3
--------------------------------------------------------------------------------
/www/sound-effects/tng_chirp_clean.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlcaDesign/sound-trigger-bot/HEAD/www/sound-effects/tng_chirp_clean.mp3
--------------------------------------------------------------------------------
/lib/events.js:
--------------------------------------------------------------------------------
1 | const { EventEmitter, once } = require('events');
2 |
3 | const events = new EventEmitter();
4 | events.at = eventName => once(events, eventName);
5 |
6 | module.exports = events;
--------------------------------------------------------------------------------
/www/sfx.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | SFX - Sound Trigger Bot
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | HTTP_PORT=8500
2 |
3 | # One or more channels to join and listen to; e.g.: list,of,channels
4 | TMI_CHANNELS=
5 |
6 | # Note: TMI_USER and TMI_PASS are not necessary for the bot in its current state,
7 | # there are chat replies yet.
8 |
9 | # Username of the bot account. Leave blank for anonymous login.
10 | TMI_USER=
11 | # An OAuth token for the username. Leave blank for anonymous login.
12 | TMI_PASS=
--------------------------------------------------------------------------------
/www/scripts/vendor/events.once.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | function once (emitter, name) {
4 | return new Promise((resolve, reject) => {
5 | const onceError = name === 'error'
6 | const listener = onceError ? resolve : (...args) => {
7 | emitter.removeListener('error', error)
8 | resolve(args)
9 | }
10 | emitter.once(name, listener)
11 | if (onceError) return
12 | const error = (err) => {
13 | emitter.removeListener(name, listener)
14 | reject(err)
15 | }
16 | emitter.once('error', error)
17 | })
18 | }
19 |
--------------------------------------------------------------------------------
/www/styles/style.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css?family=Roboto&display=swap');
2 |
3 | body {
4 | font-family: 'Roboto', sans-serif;
5 | background: hsl(0, 0%, 4%);
6 | color: white;
7 | }
8 |
9 | a {
10 | color: hsl(210, 80%, 60%);
11 | }
12 |
13 | #buttons {
14 | display: flex;
15 | }
16 |
17 | .button {
18 | padding: 6px 12px;
19 | background: hsl(210, 55%, 39%);
20 | border-radius: 4px;
21 | cursor: pointer;
22 | user-select: none;
23 | }
24 |
25 | .button:not(:last-child) {
26 | margin-right: 8px;
27 | }
28 |
29 | .button:hover {
30 | background: hsl(210, 80%, 60%);
31 | }
--------------------------------------------------------------------------------
/www/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Sound Trigger Bot
5 |
6 |
7 |
8 |
9 |
10 | Sound Trigger Bot
11 |
12 | Click here to open the SFX page.
13 |
14 | Control Panel
15 |
16 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sound-trigger-bot",
3 | "version": "1.0.0",
4 | "description": "A sound trigger bot for Twitch chat.",
5 | "main": "server.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1",
8 | "start": "node server.js"
9 | },
10 | "keywords": [
11 | "twitch",
12 | "chat",
13 | "sound",
14 | "effects",
15 | "sfx"
16 | ],
17 | "author": "Jacob Foster",
18 | "license": "ISC",
19 | "dependencies": {
20 | "@iarna/toml": "^2.2.3",
21 | "dotenv": "^8.1.0",
22 | "express": "^4.17.1",
23 | "socket.io": "^2.2.0",
24 | "tmi.js": "^1.4.5"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2019 Jacob Foster
2 |
3 | Permission to use, copy, modify, and/or distribute this software for any purpose
4 | with or without fee is hereby granted, provided that the above copyright notice
5 | and this permission notice appear in all copies.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
8 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
9 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
10 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
11 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
12 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
13 | THIS SOFTWARE.
--------------------------------------------------------------------------------
/www/scripts/index.js:
--------------------------------------------------------------------------------
1 | const apiBase = location.origin;
2 | const elements = {
3 | /** @type {Element} */
4 | toggleTTS: document.getElementById('toggle-tts'),
5 | skip: document.getElementById('skip'),
6 | };
7 | const state = {};
8 |
9 | socket.on('state', updateState);
10 |
11 | elements.toggleTTS.addEventListener('click', () => {
12 | ttsToggle();
13 | });
14 |
15 | elements.skip.addEventListener('click', () => {
16 | socket.emit('skip');
17 | });
18 |
19 | getState().then(updateState);
20 |
21 | function update() {
22 | const { toggleTTS } = elements;
23 | const { tts } = state;
24 | toggleTTS.querySelector('span').textContent = tts.enabled ? '✅' : '❌';
25 | }
26 |
27 | function updateState(_state) {
28 | Object.assign(state, _state);
29 | update();
30 | }
31 |
32 | function request({ url = '', qs = {}, method = 'get', headers = {} } = {}) {
33 | url += new URLSearchParams(qs);
34 | return fetch(url, { headers, method });
35 | }
36 |
37 | function api({ endpoint = '', qs, method }) {
38 | const url = `${apiBase}/api/${endpoint}`;
39 | return request({ url, qs, method })
40 | .then(res => res.json());
41 | }
42 |
43 | function getState() {
44 | return api({ endpoint: 'state' });
45 | }
46 |
47 | function ttsToggle() {
48 | socket.emit('toggle-tts');
49 | // return api({ endpoint: 'state/tts/toggle', method: 'post' })
50 | // .then(updateState);
51 | }
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config();
2 | const express = require('express');
3 | const socketIO = require('socket.io');
4 |
5 | const { getSoundEffects, state } = require('./lib/config');
6 | const events = require('./lib/events');
7 | const chat = require('./lib/chat-client');
8 |
9 | const app = express();
10 | const server = app.listen(process.env.HTTP_PORT, () => {
11 | console.log('Listening :' + server.address().port);
12 | });
13 | const io = socketIO(server);
14 |
15 | app.use(express.static('www', { extensions: [ 'html' ] }));
16 |
17 |
18 | app.get('/api/sounds/list', async (req, res) => {
19 | const sounds = await getSoundEffects();
20 | res.json({ sounds });
21 | });
22 |
23 | app.get('/api/state', (req, res) => {
24 | res.json(state);
25 | });
26 |
27 | app.post('/api/state/tts/toggle', (req, res) => {
28 | state.tts.enabled = !state.tts.enabled;
29 | res.json(state);
30 | updateState();
31 | });
32 |
33 | app.use((req, res, next) => {
34 | res.sendStatus(404);
35 | });
36 |
37 | app.use((err, req, res, next) => {
38 | console.error(err);
39 | res.sendStatus(500);
40 | });
41 |
42 | io.on('connection', socket => {
43 | console.log('Client connected');
44 |
45 | socket.on('disconnect', () => {
46 | console.log('Client disconnected');
47 | });
48 | });
49 |
50 | events.on('sfx', (...args) => io.emit('sfx', ...args));
51 | events.on('tts', (...args) => io.emit('tts', ...args));
52 |
53 | function updateState() {
54 | io.emit('state', state);
55 | }
56 |
57 | io.on('connect', socket => {
58 | socket.on('toggle-tts', () => {
59 | state.tts.enabled = !state.tts.enabled;
60 | updateState();
61 | });
62 | socket.on('skip', () => io.emit('skip'));
63 | });
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # TypeScript v1 declaration files
45 | typings/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Optional REPL history
57 | .node_repl_history
58 |
59 | # Output of 'npm pack'
60 | *.tgz
61 |
62 | # Yarn Integrity file
63 | .yarn-integrity
64 |
65 | # dotenv environment variables file
66 | .env
67 | .env.test
68 |
69 | # parcel-bundler cache (https://parceljs.org/)
70 | .cache
71 |
72 | # next.js build output
73 | .next
74 |
75 | # nuxt.js build output
76 | .nuxt
77 |
78 | # vuepress build output
79 | .vuepress/dist
80 |
81 | # Serverless directories
82 | .serverless/
83 |
84 | # FuseBox cache
85 | .fusebox/
86 |
87 | # DynamoDB Local files
88 | .dynamodb/
89 |
--------------------------------------------------------------------------------
/lib/config.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const { EventEmitter, once } = require('events');
3 |
4 | const tomlParse = require('@iarna/toml/parse-stream');
5 |
6 | const state = {
7 | tts: {
8 | enabled: true
9 | }
10 | };
11 |
12 | const configCache = {
13 | currentlyReading: false,
14 | events: new EventEmitter()
15 | };
16 |
17 | /**
18 | * A file object.
19 | * @typedef File
20 | * @prop {string} name The file name.
21 | * @prop {number} volume The volume to play the file at.
22 | */
23 |
24 | /**
25 | * A sound.
26 | * @typedef Sound
27 | * @property {string} name Name of the sound effect.
28 | * @property {string[] | File[]} files The location of the file relative to the
29 | * www/sounds-effects folder.
30 | * @property {string[]} aliases Aliases for the name of the sound effect.
31 | */
32 |
33 | /**
34 | * @typedef Config
35 | * @property {Sound[]} sounds A list of sounds.
36 | */
37 |
38 | /**
39 | * @returns {Promise}
40 | */
41 | function readConfig() {
42 | if(configCache.currentlyReading) {
43 | return once(configCache.events, 'config-read');
44 | }
45 | configCache.currentlyReading = true;
46 | const stream = fs.createReadStream('./config.toml');
47 | return tomlParse(stream)
48 | .then(config => {
49 | configCache.currentlyReading = false;
50 | process.nextTick(() => configCache.events.emit('config-read', config));
51 | return config;
52 | })
53 | .catch(err => {
54 | configCache.currentlyReading = false;
55 | console.error(err);
56 | configCache.events.emit('error', err);
57 | });
58 | }
59 |
60 | /**
61 | * @return {Promise}
62 | */
63 | async function getSoundEffects() {
64 | const config = await readConfig();
65 | return config.sounds;
66 | }
67 |
68 | module.exports = {
69 | readConfig,
70 | getSoundEffects,
71 |
72 | state
73 | };
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Sound Trigger Bot
2 |
3 | A Node.js bot that will listen to Twitch chat for commands to play sound effects
4 | or TTS in a hidden webpage (like a browser source in OBS).
5 |
6 | ## Do the things
7 |
8 | ```bash
9 | # Clone the repo
10 | git clone git@github.com:AlcaDesign/sound-trigger-bot.git
11 | # Change directory into the repo
12 | cd sound-trigger-bot
13 | # Copy the .env.example file to .env
14 | cp .env.example .env
15 | # Edit the .env file
16 | nano .env
17 | # Install the dependencies
18 | npm install
19 | # Start the server
20 | npm start
21 | ```
22 |
23 | By default, the port is set to `8500` in the `.env`. Go to `localhost:8500` and
24 | you'll get a link to the sfx page. This is the page that would be loaded in OBS.
25 | The main page may be used for adding more sound effects and other options later.
26 |
27 | ## Commands
28 |
29 | ### Sound Effects
30 |
31 | #### ***`!`***
32 |
33 | Sound effects are sounds files located in `www/sound-effects/` and listed in the
34 | `sounds` section of `db.json`.
35 |
36 | The DB has "ding" listed with a "ping" alias. The command "!ding" or "!ping" will play the "ding.mp3" sound effect.
37 |
38 | ### Text-To-Speech
39 |
40 | #### ***`!tts `***
41 |
42 | Play some text-to-speech with a chirp sound queued before.
43 |
44 | ## Adding more sound effects
45 |
46 | Edit the `db.json` file to include additional items in the `sounds` array and
47 | matching files in `www/sound-effects/`. The file name can include additional
48 | directories as long as its based in the prior mentioned directory.
49 |
50 | ```json
51 | {
52 | "sounds": [
53 | {
54 | "name": "ding",
55 | "file": "ding.mp3",
56 | "aliases": [ "ping" ]
57 | },
58 | {
59 | "name": "helloworld",
60 | "file": "helloworld.mp3",
61 | "aliases": [ "hello", "hello-world" ]
62 | }
63 | ]
64 | }
65 | ```
--------------------------------------------------------------------------------
/lib/chat-client.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config();
2 | const tmi = require('tmi.js');
3 |
4 | const { getSoundEffects, state } = require('./config');
5 | const events = require('./events');
6 |
7 | /** @type {import("tmi.js").Client} */
8 | const client = new tmi.Client({
9 | options: {
10 | debug: true
11 | },
12 | connection: {
13 | reconnect: true,
14 | secure: true
15 | },
16 | identity: {
17 | username: process.env.TMI_USER || undefined,
18 | password: process.env.TMI_PASS || undefined
19 | },
20 | channels: process.env.TMI_CHANNELS
21 | .trim()
22 | .split(',')
23 | .reduce((p, n) => n.trim() ? p.concat(n.trim()) : p, [])
24 | });
25 |
26 | client.connect();
27 |
28 | client.on('message', onMessageHandler);
29 |
30 | client.on('timeout', onTimeoutHandler);
31 | client.on('ban', onBanHandler);
32 |
33 | /**
34 | * @param {string} channel
35 | * @param {import("tmi.js").ChatUserstate} tags
36 | * @param {string} message
37 | * @param {boolean} self
38 | */
39 | async function onMessageHandler(channel, tags, message, self) {
40 | if(self || !message.startsWith('!')) {
41 | return;
42 | }
43 | const args = message.slice(1).split(' ');
44 | const command = args.shift().toLowerCase();
45 | const sounds = await getSoundEffects();
46 | const soundEffect = sounds.find(
47 | n => (n.name === command ||
48 | n.aliases.includes(command)) &&
49 | (!n.events || n.events.includes('sfx'))
50 | );
51 | if(soundEffect) {
52 | events.emit('sfx', { command, soundEffect });
53 | }
54 | else if(command === 'tts' || command === 'ttsr') {
55 | if(state.tts.enabled) {
56 | const voice = command === 'ttsr' ? 'random' : undefined;
57 | events.emit('tts', { text: args.join(' '), voice });
58 | }
59 | else {
60 | events.emit('tts', { text: 'Sorry, TTS is disabled.' });
61 | }
62 | }
63 | else if(command === 'sfx') {
64 | const sfxList = sounds
65 | .filter(n => !n.events || n.events.includes('sfx'))
66 | .map(n => n.name).join(', ');
67 | let message = `List of sound effects: ${sfxList}`;
68 | if(!sfxList.length) {
69 | message = 'No sound effects available.';
70 | }
71 | client.say(channel, message);
72 | }
73 | }
74 |
75 | async function onTimeoutHandler(channel, username, reason, tags) {
76 | const sounds = await getSoundEffects();
77 | const name = 'timeout';
78 | const soundEffect = sounds.find(n =>
79 | n.name === name && n.events && n.events.includes(name)
80 | );
81 | events.emit('sfx', { command: null, soundEffect });
82 | }
83 |
84 | async function onBanHandler(channel, username, reason, tags) {
85 | const sounds = await getSoundEffects();
86 | const name = 'ban';
87 | const soundEffect = sounds.find(n =>
88 | n.name === name && n.events && n.events.includes(name)
89 | );
90 | events.emit('sfx', { command: null, soundEffect });
91 | }
--------------------------------------------------------------------------------
/www/scripts/vendor/eventemitter3.min.js:
--------------------------------------------------------------------------------
1 | "use strict";var has=Object.prototype.hasOwnProperty,prefix="~";function Events(){}function EE(e,t,n){this.fn=e,this.context=t,this.once=n||!1}function addListener(e,t,n,r,i){if("function"!=typeof n)throw new TypeError("The listener must be a function");var o=new EE(n,r||e,i),s=prefix?prefix+t:t;return e._events[s]?e._events[s].fn?e._events[s]=[e._events[s],o]:e._events[s].push(o):(e._events[s]=o,e._eventsCount++),e}function clearEvent(e,t){0==--e._eventsCount?e._events=new Events:delete e._events[t]}function EventEmitter(){this._events=new Events,this._eventsCount=0}Object.create&&(Events.prototype=Object.create(null),(new Events).__proto__||(prefix=!1)),EventEmitter.prototype.eventNames=function(){var e,t,n=[];if(0===this._eventsCount)return n;for(t in e=this._events)has.call(e,t)&&n.push(prefix?t.slice(1):t);return Object.getOwnPropertySymbols?n.concat(Object.getOwnPropertySymbols(e)):n},EventEmitter.prototype.listeners=function(e){var t=prefix?prefix+e:e,n=this._events[t];if(!n)return[];if(n.fn)return[n.fn];for(var r=0,i=n.length,o=new Array(i);r} */
17 | const soundCache = new Map();
18 | const events = new EventEmitter();
19 | const queue = {
20 | isPlaying: false,
21 | list: [],
22 | currentlyPlaying: null
23 | };
24 |
25 | const audioCtx = new AudioContext();
26 | const audioGain = audioCtx.createGain();
27 | audioGain.gain.value = 1;
28 | audioGain.connect(audioCtx.destination);
29 |
30 | async function loadSound(location, dontCache = false) {
31 | if(!dontCache) {
32 | const sound = soundCache.get(location);
33 | if(sound) {
34 | return sound;
35 | }
36 | }
37 | let audioBuf;
38 | try {
39 | const res = await fetch(location);
40 | const arrayBuf = await res.arrayBuffer();
41 | audioBuf = await audioCtx.decodeAudioData(arrayBuf);
42 | } catch {
43 | console.error(`Failed to load sound at: "${location}"`);
44 | return null;
45 | }
46 | if(!dontCache) {
47 | soundCache.set(location, audioBuf);
48 | }
49 | return audioBuf;
50 | }
51 |
52 | function loadSoundNoCache(location) {
53 | return loadSound(location, true);
54 | }
55 |
56 | function playSound(audio) {
57 | return new Promise((resolve, reject) => {
58 | if(!audio) return resolve();
59 | const source = audioCtx.createBufferSource();
60 | source.buffer = audio.buffer;
61 | audioGain.gain.value = audio.volume || 1;
62 | source.connect(audioGain);
63 | source.start(audioCtx.currentTime);
64 | source.onended = () => {
65 | queue.currentlyPlaying = null;
66 | resolve();
67 | };
68 | queue.currentlyPlaying = {
69 | stop() {
70 | source.stop();
71 | resolve();
72 | }
73 | };
74 | });
75 | }
76 |
77 | async function playQueue() {
78 | if(!queue.list.length) {
79 | return;
80 | }
81 | else if(queue.isPlaying) {
82 | return once(events, 'queue-next').then(playQueue);
83 | }
84 | queue.isPlaying = true;
85 | const listItem = queue.list.shift();
86 | for(const item of listItem.items) {
87 | await playSound(item);
88 | }
89 | queue.isPlaying = false;
90 | events.emit('queue-next');
91 | }
92 |
93 | async function addToQueue(...items) {
94 | for(const [ i, n ] of items.entries()) {
95 | if(n instanceof AudioBuffer === false) {
96 | const isString = typeof n === 'string';
97 | const name = isString ? n : n.location;
98 | items[i] = {
99 | buffer: await loadSound(name),
100 | volume: isString ? 1 : n.volume || 1
101 | };
102 | }
103 | else {
104 | items[i] = { buffer: n, volume: 1 };
105 | }
106 | }
107 | queue.list.push({ items });
108 | playQueue();
109 | }
110 |
111 | socket.on('tts', async ({ text, voice: voiceInput = 'Zhiyu' }) => {
112 | const voice = voiceInput === 'random' ?
113 | voiceList[Math.floor(Math.random() * voiceList.length)] :
114 | voiceInput;
115 | const qs = new URLSearchParams({ voice, text });
116 | addToQueue(
117 | { location: chirp, volume: 0.4 },
118 | await loadSoundNoCache(`${ttsBase}?${qs}`)
119 | );
120 | });
121 |
122 | socket.on('sfx', ({ command, soundEffect }) => {
123 | const { files } = soundEffect;
124 | let file;
125 | if(files.length > 1) {
126 | const filteredFiles = files.filter(n => !soundsPlayed.includes(n));
127 | const list = filteredFiles.length ?
128 | filteredFiles :
129 | files.filter(n => n !== soundsPlayed[soundsPlayed.length - 1]);
130 | file = list[Math.floor(Math.random() * list.length)];
131 | }
132 | else {
133 | file = files[0];
134 | }
135 | const isString = typeof file === 'string';
136 | let name = isString ? file : file.name;
137 | soundsPlayed = soundsPlayed.slice(-2);
138 | if(!soundsPlayed.includes(name)) {
139 | soundsPlayed.push(name);
140 | }
141 | const location = sfxBase + name;
142 | const volume = isString ? 1 : file.volume || 1;
143 | addToQueue({ location, volume });
144 | });
145 |
146 | socket.on('skip', () => {
147 | if(queue.currentlyPlaying) {
148 | queue.currentlyPlaying.stop();
149 | }
150 | });
--------------------------------------------------------------------------------