diff --git a/changelog.json b/changelog.json index 1a7231e..98cbc81 100644 --- a/changelog.json +++ b/changelog.json @@ -3,5 +3,6 @@ "1.19 Support", "*music play file autocomplete", "Improved URL loading in *draw and *music playurl", - "You can now do *clearchat [player] without \"specific\"" + "You can now do *clearchat [player] without \"specific\"", + "Added .nbs and .txt support for music command" ] \ No newline at end of file diff --git a/commands/music.js b/commands/music.js index a0f18a5..8c07600 100644 --- a/commands/music.js +++ b/commands/music.js @@ -3,6 +3,7 @@ const fs = require('fs/promises') const { EmbedBuilder } = require('discord.js') const path = require('path') +const getFilenameFromUrl = require('../util/getFilenameFromUrl') const fileExists = require('../util/file-exists') const fileList = require('../util/file-list') const axios = require('axios') @@ -31,7 +32,7 @@ async function play (bot, values, discord, channeldc, selector, config) { filepath !== '') absolutePath = await resolve(file) else absolutePath = await resolve(filepath) - song = bot.music.load(await fs.readFile(absolutePath), path.basename(absolutePath)) + song = await bot.music.load(await fs.readFile(absolutePath), path.basename(absolutePath)) bot.music.queue.push(song) bot.music.play(song) if (discord) { @@ -44,6 +45,7 @@ async function play (bot, values, discord, channeldc, selector, config) { bot.tellraw(selector, [{ text: 'Added ', color: 'white' }, { text: song.name, color: 'gold' }, { text: ' to the song queue', color: 'white' }]) } } catch (e) { + console.log(e) if (discord) { const Embed = new EmbedBuilder() .setColor(config.discord.embedsColors.error) @@ -66,7 +68,7 @@ async function playUrl (bot, values, discord, channeldc, selector, config) { }, responseType: 'arraybuffer' }) - song = bot.music.load(response.data, url) + song = await bot.music.load(response.data, getFilenameFromUrl(url)) bot.music.queue.push(song) bot.music.play(song) if (discord) { diff --git a/plugins/music.js b/plugins/music.js index fe57615..19fd8af 100644 --- a/plugins/music.js +++ b/plugins/music.js @@ -1,6 +1,8 @@ - +const path = require('path') const { Midi } = require('@tonejs/midi') const { convertMidi } = require('../util/midi_converter') +const convertNBS = require('../util/nbs_converter') +const parseTXTSong = require('../util/txt_song_parser') const soundNames = { harp: 'minecraft:block.note_block.harp', @@ -79,11 +81,22 @@ function inject (bot) { clearInterval(interval) }) - bot.music.load = function (buffer, fallbackName = '[unknown]') { - // TODO: NBS Support - const midi = new Midi(buffer) - const song = convertMidi(midi) - if (song.name === '') song.name = fallbackName + bot.music.load = async function (buffer, fallbackName = '[unknown]') { + let song + switch (path.extname(fallbackName)) { + case '.nbs': + song = convertNBS(buffer) + break + case '.txt': + song = parseTXTSong(buffer.toString()) + break + default: + // eslint-disable-next-line no-case-declarations + const midi = new Midi(buffer) + song = convertMidi(midi) + if (song.name === '') song.name = fallbackName + break + } return song } diff --git a/util/getFilenameFromUrl.js b/util/getFilenameFromUrl.js new file mode 100644 index 0000000..0d3ea40 --- /dev/null +++ b/util/getFilenameFromUrl.js @@ -0,0 +1,14 @@ +const path = require('path') +/** + * get filename from url + * @param {string} urlStr the url + * @return {string} filename + * @example + * getFilenameFromUrl('https://sus.red/amogus.mid?verysus=true') // returns 'amogus.mid' + */ +function getFilenameFromUrl (urlStr) { + const url = new URL(urlStr) + return path.basename(url.pathname) +} + +module.exports = getFilenameFromUrl diff --git a/util/nbs_converter.js b/util/nbs_converter.js new file mode 100644 index 0000000..bbe95b0 --- /dev/null +++ b/util/nbs_converter.js @@ -0,0 +1,62 @@ +const nbs = require('./nbs_file') +const instrumentNames = [ + 'harp', + 'bass', + 'basedrum', + 'snare', + 'hat', + 'guitar', + 'flute', + 'bell', + 'chime', + 'xylophone', + 'iron_xylophone', + 'cow_bell', + 'didgeridoo', + 'bit', + 'banjo', + 'pling' +] + +function convertNBS (buf) { + const parsed = nbs.parse(buf) + const song = { + name: parsed.songName, + notes: [], + loop: false, + loopPosition: 0, + length: 0 + } + if (parsed.loop > 0) { + song.loop = true + song.loopPosition = parsed.loopStartTick + } + for (const note of parsed.nbsNotes) { + let instrument = note.instrument + if (note.instrument < instrumentNames.length) { + instrument = instrumentNames[note.instrument] + } else continue + + if (note.key < 33 || note.key > 55) continue + + const layerVolume = 100 + // will add layer volume later + + const time = tickToMs(note.tick, parsed.tempo) + song.length = Math.max(song.length, time) + + song.notes.push({ + instrument, + pitch: note.key - 33, + volume: note.velocity * layerVolume / 10000, + time + }) + } + return song +} + +function tickToMs (tick = 1, tempo) { + return Math.floor(1000 * tick * 100 / tempo) +} + +module.exports = convertNBS diff --git a/util/nbs_file.js b/util/nbs_file.js new file mode 100644 index 0000000..0cb6336 --- /dev/null +++ b/util/nbs_file.js @@ -0,0 +1,157 @@ +function parse (buffer) { + let i = 0 + + let songLength = 0 + let format = 0 + let vanillaInstrumentCount = 0 + songLength = readShort() + if (songLength === 0) { + format = readByte() + } + + if (format >= 1) { + vanillaInstrumentCount = readByte() + } + if (format >= 3) { + songLength = readShort() + } + + const layerCount = readShort() + const songName = readString() + const songAuthor = readString() + const songOriginalAuthor = readString() + const songDescription = readString() + const tempo = readShort() + const autoSaving = readByte() + const autoSavingDuration = readByte() + const timeSignature = readByte() + const minutesSpent = readInt() + const leftClicks = readInt() + const rightClicks = readInt() + const blocksAdded = readInt() + const blocksRemoved = readInt() + const origFileName = readString() + + let loop = 0 + let maxLoopCount = 0 + let loopStartTick = 0 + if (format >= 4) { + loop = readByte() + maxLoopCount = readByte() + loopStartTick = readShort() + } + + const nbsNotes = [] + let tick = -1 + while (true) { + const tickJumps = readShort() + if (tickJumps === 0) break + tick += tickJumps + + let layer = -1 + while (true) { + const layerJumps = readShort() + if (layerJumps === 0) break + layer += layerJumps + const note = nbsNote() + note.tick = tick + note.layer = layer + note.instrument = readByte() + note.key = readByte() + if (format >= 4) { + note.velocity = readByte() + note.panning = readByte() + note.pitch = readShort() + } + nbsNotes.push(note) + } + } + + const nbsLayers = [] + if (i <= buffer.length) { + for (let j = 0; j < layerCount; j++) { + const layer = nbsLayer() + layer.name = readString() + if (format >= 4) { + layer.lock = readByte() + } + layer.volume = readByte() + if (format >= 2) { + layer.stereo = readByte() + } + nbsLayers.push(layer) + } + } + + return { + songLength, + format, + vanillaInstrumentCount, + layerCount, + songName, + songAuthor, + songOriginalAuthor, + songDescription, + tempo, + autoSaving, + autoSavingDuration, + timeSignature, + minutesSpent, + leftClicks, + rightClicks, + blocksAdded, + blocksRemoved, + origFileName, + loop, + maxLoopCount, + loopStartTick, + nbsNotes, + nbsLayers + } + + function readByte () { + return buffer.readInt8(i++) + } + + function readShort () { + const short = buffer.readInt16LE(i) + i += 2 + return short + } + + function readInt () { + const int = buffer.readInt32LE(i) + i += 4 + return int + } + + function readString () { + let length = readInt() + let string = '' + for (; length > 0; length--) string += String.fromCharCode(readByte()) + return string + } +} + +function nbsNote () { + return { + tick: null, + layer: null, + instrument: null, + key: null, + velocity: 100, + panning: 100, + pitch: 0 + } +} + +function nbsLayer () { + return { + name: null, + lock: 0, + volume: null, + stereo: 100 + } +} + +module.exports = { parse, nbsNote, nbsLayer } diff --git a/util/txt_song_parser.js b/util/txt_song_parser.js new file mode 100644 index 0000000..6459c15 --- /dev/null +++ b/util/txt_song_parser.js @@ -0,0 +1,15 @@ +const { instrumentsArray } = require('minecraft-data')('1.15.2') // chip hardcoding moment + +function parseTXTSong (data) { + let length = 0 + const notes = String(data).split(/\r\n|\r|\n/).map(line => { + const [tick, pitch, instrument] = line.split(':').map(Number) + if (tick === undefined || pitch === undefined || instrument === undefined) return undefined + const time = tick * 50 + length = Math.max(length, time) + return { time, pitch, instrument: instrumentsArray[instrument].name, volume: 1 } + }).filter(note => note !== undefined) + return { name: '', notes, loop: false, loopPosition: 0, length } +} + +module.exports = parseTXTSong