KEXEL's picture
1.1
b0bfea8 verified
import { MIDISequenceData } from "./midi_sequence.js";
import { getStringBytes, readBytesAsString } from "../utils/byte_functions/string.js";
import { messageTypes, MIDIMessage } from "./midi_message.js";
import { readBytesAsUintBigEndian } from "../utils/byte_functions/big_endian.js";
import { SpessaSynthGroup, SpessaSynthGroupEnd, SpessaSynthInfo } from "../utils/loggin.js";
import { consoleColors, formatTitle, sanitizeKarLyrics } from "../utils/other.js";
import { writeMIDI } from "./midi_writer.js";
import { applySnapshotToMIDI, modifyMIDI } from "./midi_editor.js";
import { writeRMIDI } from "./rmidi_writer.js";
import { getUsedProgramsAndKeys } from "./used_keys_loaded.js";
import { IndexedByteArray } from "../utils/indexed_array.js";
/**
* BasicMIDI is the base of a complete MIDI file, used by the sequencer internally.
* BasicMIDI is not available on the main thread, as it contains the actual track data which can be large.
* It can be accessed by calling getMIDI() on the Sequencer.
*/
class BasicMIDI extends MIDISequenceData
{
/**
* The embedded soundfont in the MIDI file, represented as an ArrayBuffer, if available.
* @type {ArrayBuffer|undefined}
*/
embeddedSoundFont = undefined;
/**
* The actual track data of the MIDI file, represented as an array of tracks.
* Tracks are arrays of MIDIMessage objects.
* @type {MIDIMessage[][]}
*/
tracks = [];
/**
* If the MIDI file is a DLS RMIDI file.
* @type {boolean}
*/
isDLSRMIDI = false;
/**
* Copies a MIDI
* @param mid {BasicMIDI}
* @returns {BasicMIDI}
*/
static copyFrom(mid)
{
const m = new BasicMIDI();
m._copyFromSequence(mid);
m.isDLSRMIDI = mid.isDLSRMIDI;
m.embeddedSoundFont = mid.embeddedSoundFont ? mid.embeddedSoundFont.slice(0) : undefined; // Deep copy
m.tracks = mid.tracks.map(track => [...track]); // Shallow copy of each track array
return m;
}
/**
* Parses internal MIDI values
* @protected
*/
_parseInternal()
{
SpessaSynthGroup(
"%cInterpreting MIDI events...",
consoleColors.info
);
/**
* For karaoke files, text events starting with @T are considered titles,
* usually the first one is the title, and the latter is things such as "sequenced by" etc.
* @type {boolean}
*/
let karaokeHasTitle = false;
this.keyRange = { max: 0, min: 127 };
/**
* Will be joined with "\n" to form the final string
* @type {string[]}
*/
let copyrightComponents = [];
let copyrightDetected = false;
if (typeof this.RMIDInfo["ICOP"] !== "undefined")
{
// if RMIDI has copyright info, don't try to detect one.
copyrightDetected = true;
}
let nameDetected = false;
if (typeof this.RMIDInfo["INAM"] !== "undefined")
{
// same as with copyright
nameDetected = true;
}
// loop tracking
let loopStart = null;
let loopEnd = null;
for (let i = 0; i < this.tracks.length; i++)
{
/**
* @type {MIDIMessage[]}
*/
const track = this.tracks[i];
const usedChannels = new Set();
let trackHasVoiceMessages = false;
for (const e of track)
{
// check if it's a voice message
if (e.messageStatusByte >= 0x80 && e.messageStatusByte < 0xF0)
{
trackHasVoiceMessages = true;
// voice messages are 7-bit always
for (let j = 0; j < e.messageData.length; j++)
{
e.messageData[j] = Math.min(127, e.messageData[j]);
}
// last voice event tick
if (e.ticks > this.lastVoiceEventTick)
{
this.lastVoiceEventTick = e.ticks;
}
// interpret the voice message
switch (e.messageStatusByte & 0xF0)
{
// cc change: loop points
case messageTypes.controllerChange:
switch (e.messageData[0])
{
case 2:
case 116:
loopStart = e.ticks;
break;
case 4:
case 117:
if (loopEnd === null)
{
loopEnd = e.ticks;
}
else
{
// this controller has occurred more than once;
// this means
// that it doesn't indicate the loop
loopEnd = 0;
}
break;
case 0:
// check RMID
if (this.isDLSRMIDI && e.messageData[1] !== 0 && e.messageData[1] !== 127)
{
SpessaSynthInfo(
"%cDLS RMIDI with offset 1 detected!",
consoleColors.recognized
);
this.bankOffset = 1;
}
}
break;
// note on: used notes tracking and key range
case messageTypes.noteOn:
usedChannels.add(e.messageStatusByte & 0x0F);
const note = e.messageData[0];
this.keyRange.min = Math.min(this.keyRange.min, note);
this.keyRange.max = Math.max(this.keyRange.max, note);
break;
}
}
e.messageData.currentIndex = 0;
const eventText = readBytesAsString(e.messageData, e.messageData.length);
e.messageData.currentIndex = 0;
// interpret the message
switch (e.messageStatusByte)
{
case messageTypes.setTempo:
// add the tempo change
e.messageData.currentIndex = 0;
this.tempoChanges.push({
ticks: e.ticks,
tempo: 60000000 / readBytesAsUintBigEndian(e.messageData, 3)
});
e.messageData.currentIndex = 0;
break;
case messageTypes.marker:
// check for loop markers
const text = eventText.trim().toLowerCase();
switch (text)
{
default:
break;
case "start":
case "loopstart":
loopStart = e.ticks;
break;
case "loopend":
loopEnd = e.ticks;
}
e.messageData.currentIndex = 0;
break;
case messageTypes.copyright:
if (!copyrightDetected)
{
e.messageData.currentIndex = 0;
copyrightComponents.push(readBytesAsString(
e.messageData,
e.messageData.length,
undefined,
false
));
e.messageData.currentIndex = 0;
}
break;
case messageTypes.lyric:
// note here: .kar files sometimes just use...
// lyrics instead of text because why not (of course)
// perform the same check for @KMIDI KARAOKE FILE
if (eventText.trim().startsWith("@KMIDI KARAOKE FILE"))
{
this.isKaraokeFile = true;
SpessaSynthInfo("%cKaraoke MIDI detected!", consoleColors.recognized);
}
if (this.isKaraokeFile)
{
// replace the type of the message with text
e.messageStatusByte = messageTypes.text;
}
else
{
// add lyrics like a regular midi file
this.lyrics.push(e.messageData);
this.lyricsTicks.push(e.ticks);
break;
}
// kar: treat the same as text
// fallthrough
case messageTypes.text:
// possibly Soft Karaoke MIDI file
// it has a text event at the start of the file
// "@KMIDI KARAOKE FILE"
const checkedText = eventText.trim();
if (checkedText.startsWith("@KMIDI KARAOKE FILE"))
{
this.isKaraokeFile = true;
SpessaSynthInfo("%cKaraoke MIDI detected!", consoleColors.recognized);
}
else if (this.isKaraokeFile)
{
// check for @T (title)
// or @A because it is a title too sometimes?
// IDK it's strange
if (checkedText.startsWith("@T") || checkedText.startsWith("@A"))
{
if (!karaokeHasTitle)
{
this.midiName = checkedText.substring(2).trim();
karaokeHasTitle = true;
nameDetected = true;
// encode to rawMidiName
this.rawMidiName = getStringBytes(this.midiName);
}
else
{
// append to copyright
copyrightComponents.push(checkedText.substring(2).trim());
}
}
else if (checkedText[0] !== "@")
{
// non @: the lyrics
this.lyrics.push(sanitizeKarLyrics(e.messageData));
this.lyricsTicks.push(e.ticks);
}
}
break;
case messageTypes.trackName:
break;
}
}
// add used channels
this.usedChannelsOnTrack.push(usedChannels);
// track name
this.trackNames[i] = "";
const trackName = track.find(e => e.messageStatusByte === messageTypes.trackName);
if (trackName)
{
trackName.messageData.currentIndex = 0;
const name = readBytesAsString(trackName.messageData, trackName.messageData.length);
this.trackNames[i] = name;
// If the track has no voice messages, its "track name" event (if it has any)
// is some metadata.
// Add it to copyright
if (!trackHasVoiceMessages)
{
copyrightComponents.push(name);
}
}
}
// reverse the tempo changes
this.tempoChanges.reverse();
SpessaSynthInfo(
`%cCorrecting loops, ports and detecting notes...`,
consoleColors.info
);
const firstNoteOns = [];
for (const t of this.tracks)
{
const firstNoteOn = t.find(e => (e.messageStatusByte & 0xF0) === messageTypes.noteOn);
if (firstNoteOn)
{
firstNoteOns.push(firstNoteOn.ticks);
}
}
this.firstNoteOn = Math.min(...firstNoteOns);
SpessaSynthInfo(
`%cFirst note-on detected at: %c${this.firstNoteOn}%c ticks!`,
consoleColors.info,
consoleColors.recognized,
consoleColors.info
);
if (loopStart !== null && loopEnd === null)
{
// not a loop
loopStart = this.firstNoteOn;
loopEnd = this.lastVoiceEventTick;
}
else
{
if (loopStart === null)
{
loopStart = this.firstNoteOn;
}
if (loopEnd === null || loopEnd === 0)
{
loopEnd = this.lastVoiceEventTick;
}
}
/**
*
* @type {{start: number, end: number}}
*/
this.loop = { start: loopStart, end: loopEnd };
SpessaSynthInfo(
`%cLoop points: start: %c${this.loop.start}%c end: %c${this.loop.end}`,
consoleColors.info,
consoleColors.recognized,
consoleColors.info,
consoleColors.recognized
);
// determine ports
let portOffset = 0;
this.midiPorts = [];
this.midiPortChannelOffsets = [];
for (let trackNum = 0; trackNum < this.tracks.length; trackNum++)
{
this.midiPorts.push(-1);
if (this.usedChannelsOnTrack[trackNum].size === 0)
{
continue;
}
for (const e of this.tracks[trackNum])
{
if (e.messageStatusByte !== messageTypes.midiPort)
{
continue;
}
const port = e.messageData[0];
this.midiPorts[trackNum] = port;
if (this.midiPortChannelOffsets[port] === undefined)
{
this.midiPortChannelOffsets[port] = portOffset;
portOffset += 16;
}
}
}
// fix midi ports:
// midi tracks without ports will have a value of -1
// if all ports have a value of -1, set it to 0,
// otherwise take the first midi port and replace all -1 with it,
// why would we do this?
// some midis (for some reason) specify all channels to port 1 or else,
// but leave the conductor track with no port pref.
// this spessasynth to reserve the first 16 channels for the conductor track
// (which doesn't play anything) and use the additional 16 for the actual ports.
let defaultPort = Infinity;
for (let port of this.midiPorts)
{
if (port !== -1)
{
if (defaultPort > port)
{
defaultPort = port;
}
}
}
if (defaultPort === Infinity)
{
defaultPort = 0;
}
this.midiPorts = this.midiPorts.map(port => port === -1 ? defaultPort : port);
// add fake port if empty
if (this.midiPortChannelOffsets.length === 0)
{
this.midiPortChannelOffsets = [0];
}
if (this.midiPortChannelOffsets.length < 2)
{
SpessaSynthInfo(`%cNo additional MIDI Ports detected.`, consoleColors.info);
}
else
{
this.isMultiPort = true;
SpessaSynthInfo(`%cMIDI Ports detected!`, consoleColors.recognized);
}
// midi name
if (!nameDetected)
{
if (this.tracks.length > 1)
{
// if more than 1 track and the first track has no notes,
// just find the first trackName in the first track.
if (
this.tracks[0].find(
message => message.messageStatusByte >= messageTypes.noteOn
&&
message.messageStatusByte < messageTypes.polyPressure
) === undefined
)
{
let name = this.tracks[0].find(message => message.messageStatusByte === messageTypes.trackName);
if (name)
{
this.rawMidiName = name.messageData;
name.messageData.currentIndex = 0;
this.midiName = readBytesAsString(name.messageData, name.messageData.length, undefined, false);
}
}
}
else
{
// if only 1 track, find the first "track name" event
let name = this.tracks[0].find(message => message.messageStatusByte === messageTypes.trackName);
if (name)
{
this.rawMidiName = name.messageData;
name.messageData.currentIndex = 0;
this.midiName = readBytesAsString(name.messageData, name.messageData.length, undefined, false);
}
}
}
if (!copyrightDetected)
{
this.copyright = copyrightComponents
// trim and group newlines into one
.map(c => c.trim().replace(/(\r?\n)+/g, "\n"))
// remove empty strings
.filter(c => c.length > 0)
// join with newlines
.join("\n") || "";
}
this.midiName = this.midiName.trim();
this.midiNameUsesFileName = false;
// if midiName is "", use the file name
if (this.midiName.length === 0)
{
SpessaSynthInfo(
`%cNo name detected. Using the alt name!`,
consoleColors.info
);
this.midiName = formatTitle(this.fileName);
this.midiNameUsesFileName = true;
// encode it too
this.rawMidiName = new Uint8Array(this.midiName.length);
for (let i = 0; i < this.midiName.length; i++)
{
this.rawMidiName[i] = this.midiName.charCodeAt(i);
}
}
else
{
SpessaSynthInfo(
`%cMIDI Name detected! %c"${this.midiName}"`,
consoleColors.info,
consoleColors.recognized
);
}
// if the first event is not at 0 ticks, add a track name
// https://github.com/spessasus/SpessaSynth/issues/145
if (!this.tracks.some(t => t[0].ticks === 0))
{
const track = this.tracks[0];
// can copy
track.unshift(new MIDIMessage(
0,
messageTypes.trackName,
new IndexedByteArray(this.rawMidiName.buffer)
));
}
/**
* The total playback time, in seconds
* @type {number}
*/
this.duration = this.MIDIticksToSeconds(this.lastVoiceEventTick);
SpessaSynthInfo("%cSuccess!", consoleColors.recognized);
SpessaSynthGroupEnd();
}
/**
* Updates all internal values
*/
flush()
{
for (const t of this.tracks)
{
// sort the track by ticks
t.sort((e1, e2) => e1.ticks - e2.ticks);
}
this._parseInternal();
}
}
BasicMIDI.prototype.writeMIDI = writeMIDI;
BasicMIDI.prototype.modifyMIDI = modifyMIDI;
BasicMIDI.prototype.applySnapshotToMIDI = applySnapshotToMIDI;
BasicMIDI.prototype.writeRMIDI = writeRMIDI;
BasicMIDI.prototype.getUsedProgramsAndKeys = getUsedProgramsAndKeys;
export { BasicMIDI };