├── 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 |
17 |
Toggle TTS: ???
18 | 19 |
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 | }); --------------------------------------------------------------------------------