@camoto/gamemusic
Version:
Read and write music files used by DOS games
404 lines (362 loc) • 11.5 kB
JavaScript
/*
* Standard MIDI format, type-1 (single file, multi track) handler.
*
* This file format is fully documented on the ModdingWiki:
* http://www.shikadi.net/moddingwiki/MID_Format
*
* Copyright (C) 2010-2021 Adam Nielsen <malvineous@shikadi.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const FORMAT_ID = 'mus-mid-type1';
import Debug from '../util/debug.js';
const debug = Debug.extend(FORMAT_ID);
import assert from 'assert';
import { RecordBuffer, RecordType } from '@camoto/record-io-buffer';
import MusicHandler from '../interface/musicHandler.js';
import Music from '../interface/music/index.js';
import UtilMusic from '../util/music.js';
import {
default as UtilMIDI,
parseSMF,
parseMIDI,
generateSMF,
generateMIDI,
} from '../util/midi/index.js';
// Length of "MThd" header block.
const MID_MTHD_LEN = 8 + 6;
const MID_DEFAULT_TICKS_PER_QUARTER_NOTE = 48;
const recordTypes = {
mthd: {
signature: RecordType.string.fixed.noTerm(4),
len: RecordType.int.u32be, // length not including signature or len
type: RecordType.int.u16be,
trackCount: RecordType.int.u16be,
ticksPerQuarterNote: RecordType.int.u16be,
},
mtrk: {
signature: RecordType.string.fixed.noTerm(4),
len: RecordType.int.u32be, // length not including signature or len
},
};
export default class Music_MID_Type1 extends MusicHandler
{
static metadata() {
let md = {
...super.metadata(),
id: FORMAT_ID,
title: 'Standard MIDI file (type-1 / multi-track)',
games: [],
glob: [
'*.mid',
],
caps: {
channelMap: [
{
name: 'MIDI',
mappings: [
{
name: 'General MIDI (1..16, P10)',
channels: [
{
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 0,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 1,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 2,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 3,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 4,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 5,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 6,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 7,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 8,
}, {
type: Music.TrackConfiguration.ChannelType.MIDIP,
target: 9,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 10,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 11,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 12,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 13,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 14,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 15,
},
],
}, {
name: 'Microsoft Basic MIDI (13..16, P16)',
channels: [
{
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 12,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 13,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 14,
}, {
type: Music.TrackConfiguration.ChannelType.MIDIP,
target: 15,
},
],
}, {
name: 'Microsoft Extended MIDI (1..10, P10)',
channels: [
{
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 0,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 1,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 2,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 3,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 4,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 5,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 6,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 7,
}, {
type: Music.TrackConfiguration.ChannelType.MIDI,
target: 8,
}, {
type: Music.TrackConfiguration.ChannelType.MIDIP,
target: 9,
},
],
},
],
},
],
tags: {},
supportedEvents: UtilMIDI.midiSupportedEvents,
patchNames: false,
},
};
return md;
}
static supps() {
return null;
}
static identify(content) {
// Files must contain at least the signature.
if (content.length < MID_MTHD_LEN) {
return {
valid: false,
reason: `File too short, ${content.length} bytes isn't enough to `
+ `read the ${MID_MTHD_LEN}-byte header.`
};
}
let buffer = new RecordBuffer(content);
const header = buffer.readRecord(recordTypes.mthd);
if (header.signature !== 'MThd') {
return {
valid: false,
reason: `Signature doesn't match.`,
};
}
if (header.len < 6) {
return {
valid: false,
reason: `Header too short.`,
};
}
if (header.type !== 1) {
return {
valid: false,
reason: `File is type-${header.type}, we only support type-1.`,
};
}
return {
valid: true,
};
}
static parse(content) {
const debug = Debug.extend('parse');
let buffer = new RecordBuffer(content.main);
if (buffer.length < MID_MTHD_LEN) {
throw new Error(`File too short, ${buffer.length} bytes isn't enough to `
+ `read the ${MID_MTHD_LEN}-byte header.`);
}
const header = buffer.readRecord(recordTypes.mthd);
debug(`Format type-${header.type}, reading as type-0/type-1`);
let music = new Music();
music.initialTempo.ticksPerQuarterNote = (
header.ticksPerQuarterNote || MID_DEFAULT_TICKS_PER_QUARTER_NOTE
);
music.initialTempo.usPerQuarterNote = 500000; // MIDI default
music.patterns[0] = new Music.Pattern();
let idxNextTrack = 0;
for (let midiTrack = 0; midiTrack < header.trackCount; midiTrack++) {
const mtrk = buffer.readRecord(recordTypes.mtrk);
if (mtrk.signature !== 'MTrk') {
throw new Error(`Missing expected MTrk signature on track ${midiTrack}.`);
}
const midiTrackData = buffer.getU8(buffer.getPos(), mtrk.len);
buffer.seekRel(mtrk.len);
const midiEvents = parseSMF(midiTrackData);
const { events } = parseMIDI(midiEvents, music.patches, music.initialTempo);
// Split the single long list of events into tracks.
const fnTrackConfig = ev => UtilMIDI.standardTrackSplitConfig(idxNextTrack, ev);
const { trackConfig, pattern } = UtilMusic.splitEvents(events, fnTrackConfig);
// Append the new tracks onto the existing ones.
idxNextTrack += trackConfig.length;
music.trackConfig = [
...music.trackConfig,
...trackConfig,
];
music.patterns[0].tracks = [
...music.patterns[0].tracks,
...pattern.tracks,
];
}
// See if there's an initial tempo event.
UtilMusic.initialEvents(music, ev => {
if (ev.type === Music.TempoEvent) {
// Found one, scrap the default tempo and use this instead.
debug('Found an existing initial tempo event:', ev);
music.initialTempo = ev;
return null; // delete the event from the track
}
return false; // keep going
});
return music;
}
static generate(music)
{
const debug = Debug.extend('generate');
// Only need to write an extra tempo event if the usPerQuarterNote value is
// not the default.
let outTempo = Math.round(music.initialTempo.usPerQuarterNote) !== 500000;
if (outTempo) {
// Find the first tempo event, if one already exists
UtilMusic.initialEvents(music, ev => {
if (ev.type === Music.TempoEvent) {
// There is already an early tempo event, so we don't have to insert
// one.
debug('Found an existing tempo event:', ev);
outTempo = false;
return true; // done
}
return false; // keep going
});
}
// Combine all the patterns into a single long multi-track pattern.
let pattern = UtilMusic.mergePatterns(music.patterns);
assert.ok(pattern);
// Start writing the .mid file to a memory buffer.
let binMID = new RecordBuffer(65536);
const mthd = {
signature: 'MThd',
len: 6, // length not including signature or len
type: 1,
trackCount: music.trackConfig.length,
ticksPerQuarterNote: music.initialTempo.ticksPerQuarterNote,
};
binMID.writeRecord(recordTypes.mthd, mthd);
let allWarnings = [];
if (outTempo) {
debug(`Adding initial tempo event: `
+ `${music.initialTempo.usPerQuarterNote.toFixed(2)} µs/qn, `
+ `${music.initialTempo.ticksPerQuarterNote} t/qn, `
+ `${music.initialTempo.usPerTick.toFixed(2)} µs/t`);
// There was no initial tempo event, and the song starts with a
// nonstandard tempo, so set that as an initial event now.
pattern.tracks[0].events.unshift(music.initialTempo);
} else {
debug('Not adding any extra tempo events');
}
// Run through each track and write it as an MTrk block to the .mid file.
for (let idxTrack = 0; idxTrack < pattern.tracks.length; idxTrack++) {
const track = pattern.tracks[idxTrack];
const trackCfg = music.trackConfig[idxTrack];
// Convert the Event objects into intermediate MIDI-event structures.
// Event objects used by gamemusicjs are very generic, whereas the
// resulting MIDI-event list closely matches the structure of MIDI data,
// just as an array of objects rather than binary data.
let { midiEvents, warnings } = generateMIDI(
track.events,
music.patches,
trackCfg
);
allWarnings = {
...allWarnings,
...warnings,
};
// Replace any tempo events with meta events, as that is how tempo changes
// are represented in .mid files.
midiEvents = UtilMIDI.tempoAsMetaEvent(midiEvents);
// Always end a track with the same meta event.
midiEvents.push({
type: 'meta',
metaType: 0x2F,
data: [],
});
// Now convert the array of MIDI-events into actual binary MIDI data that
// can be written directly to a .mid file.
const trackContent = generateSMF(midiEvents, {
useRunningStatus: true,
});
// Add the MTrk header and write that and the binary data to the file.
const mtrk = {
signature: 'MTrk',
len: trackContent.length, // length not including signature or len
};
binMID.writeRecord(recordTypes.mtrk, mtrk);
binMID.put(trackContent);
}
return {
content: {
main: binMID.getU8(),
},
warnings: allWarnings,
};
}
}