m8-js
Version:
Library for loading and interacting with Dirtywave M8 instrument/song files.
513 lines (432 loc) • 15 kB
JavaScript
/* Copyright 2022 Jeremy Whitlock
*
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const { DefaultScales, Scale } = require('./Scale')
const { LATEST_M8_VERSION, VERSION_2_5_0 } = require('../constants')
const Chain = require('./internal/Chain')
const EffectsSettings = require('./internal/EffectsSettings')
const Groove = require('./internal/Groove')
const M8File = require('./internal/M8File')
const M8Version = require('./internal/M8Version')
const MIDIMapping = require('./internal/MIDIMapping')
const MIDISettings = require('./internal/MIDISettings')
const MixerSettings = require('./internal/MixerSettings')
const None = require('./instruments/None')
const Phrase = require('./internal/Phrase')
const SongStep = require('./internal/SongStep')
const Table = require('./internal/Table')
const FMSynth = require('./instruments/FMSynth')
const Macrosynth = require('./instruments/Macrosynth')
const MIDIOut = require('./instruments/MIDIOut')
const Sampler = require('./instruments/Sampler')
const Wavsynth = require('./instruments/Wavsynth')
/**
* Represents a Song.
*
* @class
*
* @augments module:m8-js/lib/types/internal.M8File
* @memberof module:m8-js/lib/types
*/
class Song extends M8File {
/** @member {Array<module:m8-js/lib/types/internal.Chain>} */
chains
/** @member {String} */
directory
/** @member {module:m8-js/lib/types/internal.EffectsSettings} */
effectsSettings
/** @member {Array<module:m8-js/lib/types/internal.Groove>} */
grooves
/** @member {Array<module:m8-js/lib/types/instruments.FMSYNTH|module:m8-js/lib/types/instruments.MACROSYNTH|module:m8-js/lib/types/instruments.MIDIOUT|module:m8-js/lib/types/instruments.NONE|module:m8-js/lib/types/instruments.SAMPLER|module:m8-js/lib/types/instruments.WAVSYNTH>} */
instruments
/** @member {Number} */
key
/** @member {module:m8-js/lib/types/internal.MIDISettings} */
midiSettings
/** @member {module:m8-js/lib/types/internal.MixerSettings} */
mixerSettings
/** @member {String} */
name
/** @member {Array<module:m8-js/lib/types/internal.Phrase>} */
phrases
/** @member {Number} */
quantize
/** @member {Array<module:m8-js/lib/types.Scale>} */
scales
/** @member {Array<module:m8-js/lib/types/internal.SongStep>} */
steps
/** @member {Array<module:m8-js/lib/types/internal.Table>} */
tables
/** @member {Number} */
tempo
/** @member {Number} */
transpose
/**
* Create a Song.
*
* @param {module:m8-js/lib/types/internal.M8FileReader|module:m8-js/lib/types/internal.M8Version} [m8FileReaderOrVersion] - The M8
* version of the Song (or the M8FileReader used to read the M8 file)
*/
constructor (m8FileReaderOrVersion) {
if (typeof m8FileReaderOrVersion === 'undefined') {
super(M8File.TYPES.Song, LATEST_M8_VERSION)
} else {
if (m8FileReaderOrVersion.constructor.name === 'M8FileReader') {
super(m8FileReaderOrVersion)
} else {
super(M8File.TYPES.Song, m8FileReaderOrVersion)
}
}
this.chains = Array.from({ length: 255 }, () => new Chain())
this.directory = ''
this.effectsSettings = new EffectsSettings()
this.grooves = Array.from({ length: 32 }, () => new Groove())
this.instruments = Array.from({ length: 128 }, () => new None(this.m8FileReader))
this.key = 0x00
this.midiMappings = Array.from({ length: 128 }, () => new MIDIMapping())
this.midiSettings = new MIDISettings()
this.mixerSettings = new MixerSettings()
this.name = ''
this.phrases = Array.from({ length: 255 }, () => new Phrase())
this.quantize = 0x00
// scales is initialized below
this.steps = Array.from({ length: 256 }, () => new SongStep())
this.tables = Array.from({ length: 256 }, () => new Table())
this.tempo = 0x78 // 120
this.transpose = 0x00
if (this.m8FileVersion.compare(VERSION_2_5_0) >= 0) {
this.scales = DefaultScales
} else {
this.scales = undefined
}
// Link Instrument tables
for (let i = 0; i < this.instruments.length; i++) {
this.instruments[i].table = this.tables[i]
}
}
/**
* @inheritdoc
*/
asObject () {
return {
...this.headerAsObject(),
chains: this.chains.map((chain) => chain.asObject()),
directory: this.directory,
effectsSettings: this.effectsSettings.asObject(),
grooves: this.grooves.map((groove) => groove.asObject()),
instruments: this.instruments.map((instrument) => instrument.asObject()),
key: this.key,
midiMappings: this.midiMappings.map((mapping) => mapping.asObject()),
midiSettings: this.midiSettings.asObject(),
mixerSettings: this.mixerSettings.asObject(),
name: this.name,
phrases: this.phrases.map((phrase) => phrase.asObject()),
quantize: this.quantize,
scales: this.scales?.map((scale) => scale.asObject()),
steps: this.steps.map((step) => step.asObject()),
tables: this.tables.map((table) => table.asObject()),
tempo: this.tempo,
transpose: this.transpose
}
}
/**
* Returns the Instrument instance for the provided Phrase step.
*
* @param {Number} trackNum - The track number (0-based)
* @param {Number} songStepNum - The song step
* @param {Number} chainStepNum - The Chain step
* @param {Number} phraseStepNum - The Phrase step
*
* @returns {module:m8-js/lib/types/instruments.FMSYNTH|module:m8-js/lib/types/instruments.MACROSYNTH|module:m8-js/lib/types/instruments.MIDIOUT|module:m8-js/lib/types/instruments.NONE|module:m8-js/lib/types/instruments.SAMPLER|module:m8-js/lib/types/instruments.WAVSYNTH}
*/
findPhraseStepInstrument (trackNum, songStepNum, chainStepNum, phraseStepNum) {
const songStep = this.steps[songStepNum]
const chainName = songStep.tracks[trackNum]
if (chainName === 0xFF) {
return undefined
}
const chain = this.chains[chainName]
const chainStep = chain.steps[chainStepNum]
if (chainStep.phrase === 0xFF) {
return undefined
}
// Collect all parent chains
const chains = []
for (let i = 0; i <= chainStepNum; i++) {
const chainName = this.steps[i].tracks[trackNum]
if (chainName !== 0xFF) {
chains.push(this.chains[chainName])
}
}
// Collect all parent phrases for the phrase step
const phrases = []
for (let i = 0; i < phraseStepNum - 1; i++) {
const chainStep = chain.steps[i]
if (chainStep.phrase !== 0xFF) {
phrases.push(this.phrases[chainStep.phrase])
}
}
// Attempt to see if the phrase step can identify its own instrument
let instrNum = this.phrases[chainStep.phrase].findPhraseStepInstrumentNum(phraseStepNum)
let instr
if (instrNum !== 0xFF) {
instr = this.instruments[instrNum]
}
while (typeof instr === 'undefined') {
// Attempt to see if the phrase can identify its instrument
while (phrases.length > 0) {
const phrase = phrases.pop()
instrNum = phrase.findPhraseStepInstrumentNum()
if (instrNum !== 0xFF) {
instr = this.instruments[instrNum]
break
}
}
if (typeof instr !== 'undefined') {
break
}
const chain = chains.pop()
if (typeof chain === 'undefined') {
break
}
for (let i = 0; i < chain.steps.length; i++) {
const phraseNum = chain.steps[i].phrase
if (phraseNum !== 0xFF) {
phrases.push(this.phrases[phraseNum])
}
}
}
return instr
}
/**
* Returns whether the Chain is considered empty.
*
* @param {Number} num - The Chain number
*
* @returns {Boolean}
*/
isChainEmpty (num) {
const chain = this.chains[num]
let empty = true
for (let i = 0; i < chain.steps.length; i++) {
// If we already know the chain is empty, no sense in continuing to check
if (!empty) {
break
}
const step = chain.steps[i]
// A chain is considered empty if all of its phrases are empty
if (step.phrase !== 0xFF) {
if (!this.isPhraseEmpty(step.phrase)) {
empty = false
}
}
}
return empty
}
/**
* Returns whether the Phrase is considered empty.
*
* @param {Number} num - The Phrase number
*
* @returns {Boolean}
*/
isPhraseEmpty (num) {
const phrase = this.phrases[num]
let empty = true
// Documentation says that a phrase is empty if it doesn't have any notes but it appears that FX can be present
// without a note and count as non-empty.
//
// fxCmdToStr is called without a song and that is only because we don't care about the real value, just if it's
// empty of the 'KIL' command.
for (let i = 0; i < phrase.steps.length; i++) {
const step = phrase.steps[i]
const instr = new None() // We don't care about instrument commands at this point
if ([step.note, step.fx[0].commandToStr(instr), step.fx[1].commandToStr(instr), step.fx[2].commandToStr(instr)].filter((val, j) => {
if (j === 0) {
return val !== 0xFF
} else {
return ['---', 'KIL'].indexOf(val) === -1
}
}).length > 0) {
empty = false
}
}
return empty
}
/**
* Returns whether or not the phrase in question is used elsewhere
*
* @param {Number} num - The phrase number
*
* @returns {Boolean}
*/
isPhraseUsageUnique (num) {
let phraseUsageCount = 0
// Documentation says that the displayed phrase name will have an '*' when used elsewhere in the chain or song.
for (let i = 0; i < this.chains.length; i++) {
// If we know that a phrase isn't unique (used more than once), we don't need to keep looking
if (phraseUsageCount > 1) {
break
}
const chain = this.chains[i]
for (let j = 0; j < chain.steps.length; j++) {
if (chain.steps[j].phrase === num) {
phraseUsageCount += 1
// If we know that a phrase isn't unique (used more than once), we don't need to keep looking
if (phraseUsageCount > 1) {
break
}
}
}
}
return phraseUsageCount > 1
}
/**
* @inheritdoc
*/
static fromObject (object) {
const m8Version = M8Version.fromObject(object?.fileMetadata?.version)
const song = new Song(m8Version)
// Do not overwrite the default value if there was no provided value
;['directory', 'key', 'name', 'quantize', 'tempo', 'transpose'].forEach((prop) => {
const value = object?.[prop]
if (typeof value !== 'undefined') {
song[prop] = value
}
})
for (let i = 0; i < song.chains.length; i++) {
const value = object?.chains?.[i]
if (typeof value === 'object') {
song.chains[i] = Chain.fromObject(value)
}
}
song.effectsSettings = EffectsSettings.fromObject(object?.effectsSettings)
for (let i = 0; i < song.grooves.length; i++) {
const value = object?.grooves?.[i]
if (typeof value === 'object') {
song.grooves[i] = Groove.fromObject(value)
}
}
// We have to handle Tables before Instruments so that when handling Instruments, we can wire Tables up properly
for (let i = 0; i < song.tables.length; i++) {
const value = object?.tables?.[i]
if (typeof value === 'object') {
song.tables[i] = Table.fromObject(value)
}
}
for (let i = 0; i < song.instruments.length; i++) {
// We have to recreate the fileMetadata so that fromObject will wire up the proper M8 Version
const value = {
fileMetadata: {
type: M8File.TYPES.Instrument,
version: m8Version.asObject()
},
...object?.instruments?.[i]
}
let instr
if (typeof value === 'object') {
switch (value.kindStr) {
case 'FMSYNTH':
instr = FMSynth.fromObject(value)
break
case 'MACROSYN':
instr = Macrosynth.fromObject(value)
break
case 'MIDI OUT':
instr = MIDIOut.fromObject(value)
break
case 'SAMPLER':
instr = Sampler.fromObject(value)
break
case 'WAVSYNTH':
instr = Wavsynth.fromObject(value)
break
default:
instr = undefined
}
if (typeof instr !== 'undefined') {
song.instruments[i] = instr
if (typeof instr.table !== 'undefined') {
song.tables[i] = instr.table
} else {
instr.table = song.tables[i]
}
}
}
}
for (let i = 0; i < song.midiMappings.length; i++) {
const value = object?.midiMappings?.[i]
if (typeof value === 'object') {
song.midiMappings[i] = MIDIMapping.fromObject(value)
}
}
song.midiSettings = MIDISettings.fromObject(object?.midiSettings)
song.mixerSettings = MixerSettings.fromObject(object?.mixerSettings)
for (let i = 0; i < song.phrases.length; i++) {
const value = object?.phrases?.[i]
if (typeof value === 'object') {
song.phrases[i] = Phrase.fromObject(value)
}
}
if (song.m8FileVersion.compare(VERSION_2_5_0) >= 0) {
for (let i = 0; i < song.scales.length; i++) {
// We have to recreate the fileMetadata so that fromObject will wire up the proper M8 Version
const value = {
fileMetadata: {
type: M8File.TYPES.Scale,
version: m8Version.asObject()
},
...object?.scales?.[i]
}
if (typeof value === 'object') {
song.scales[i] = Scale.fromObject(value)
}
}
}
for (let i = 0; i < song.steps.length; i++) {
const value = object?.steps?.[i]
if (typeof value === 'object') {
song.steps[i] = SongStep.fromObject(value)
}
}
return song
}
/**
* @inheritdoc
*/
static getObjectProperties () {
return [
...this.getHeaderObjectProperties(),
'chains',
'directory',
'effectsSettings',
'grooves',
'instruments',
'key',
'midiMappings',
'midiSettings',
'mixerSettings',
'name',
'phrases',
'quantize',
'scales',
'steps',
'tables',
'tempo',
'transpose'
]
}
}
// Exports
module.exports = Song