KEXEL's picture
1.1
b0bfea8 verified
import { combineArrays, IndexedByteArray } from "../utils/indexed_array.js";
import { writeRIFFOddSize } from "../soundfont/basic_soundfont/riff_chunk.js";
import { getStringBytes, getStringBytesZero } from "../utils/byte_functions/string.js";
import { messageTypes, midiControllers, MIDIMessage } from "./midi_message.js";
import { getGsOn } from "./midi_editor.js";
import { SpessaSynthGroup, SpessaSynthGroupEnd, SpessaSynthInfo } from "../utils/loggin.js";
import { consoleColors } from "../utils/other.js";
import { writeLittleEndian } from "../utils/byte_functions/little_endian.js";
import { DEFAULT_PERCUSSION } from "../synthetizer/synth_constants.js";
import { chooseBank, isSystemXG, parseBankSelect } from "../utils/xg_hacks.js";
import { isGM2On, isGMOn, isGSDrumsOn, isGSOn, isXGOn } from "../utils/sysex_detector.js";
/**
* @enum {string}
*/
export const RMIDINFOChunks = {
name: "INAM",
album: "IPRD",
album2: "IALB",
artist: "IART",
genre: "IGNR",
picture: "IPIC",
copyright: "ICOP",
creationDate: "ICRD",
comment: "ICMT",
engineer: "IENG",
software: "ISFT",
encoding: "IENC",
midiEncoding: "MENC",
bankOffset: "DBNK"
};
const FORCED_ENCODING = "utf-8";
const DEFAULT_COPYRIGHT = "Created using SpessaSynth";
/**
* @typedef {Object} RMIDMetadata
* @property {string|undefined} name - the name of the file
* @property {string|undefined} engineer - the engineer who worked on the file
* @property {string|undefined} artist - the artist
* @property {string|undefined} album - the album
* @property {string|undefined} genre - the genre of the song
* @property {ArrayBuffer|undefined} picture - the image for the file (album cover)
* @property {string|undefined} comment - the coment of the file
* @property {string|undefined} creationDate - the creation date of the file
* @property {string|undefined} copyright - the copyright of the file
* @property {string|unescape} midiEncoding - the encoding of the inner MIDI file
*/
/**
* Writes an RMIDI file
* @this {BasicMIDI}
* @param soundfontBinary {Uint8Array}
* @param soundfont {BasicSoundBank}
* @param bankOffset {number} the bank offset for RMIDI
* @param encoding {string} the encoding of the RMIDI info chunk
* @param metadata {RMIDMetadata} the metadata of the file. Optional. If provided, the encoding is forced to utf-8/
* @param correctBankOffset {boolean}
* @returns {IndexedByteArray}
*/
export function writeRMIDI(
soundfontBinary,
soundfont,
bankOffset = 0,
encoding = "Shift_JIS",
metadata = {},
correctBankOffset = true
)
{
const mid = this;
SpessaSynthGroup("%cWriting the RMIDI File...", consoleColors.info);
SpessaSynthInfo(
`%cConfiguration: Bank offset: %c${bankOffset}%c, encoding: %c${encoding}`,
consoleColors.info,
consoleColors.value,
consoleColors.info,
consoleColors.value
);
SpessaSynthInfo("metadata", metadata);
SpessaSynthInfo("Initial bank offset", mid.bankOffset);
if (correctBankOffset)
{
// Add the offset to the bank.
// See https://github.com/spessasus/sf2-rmidi-specification#readme
// also fix presets that don't exist
// since midi player6 doesn't seem to default to 0 when non-existent...
let system = "gm";
/**
* The unwanted system messages such as gm/gm2 on
* @type {{tNum: number, e: MIDIMessage}[]}
*/
let unwantedSystems = [];
/**
* indexes for tracks
* @type {number[]}
*/
const eventIndexes = Array(mid.tracks.length).fill(0);
let remainingTracks = mid.tracks.length;
function findFirstEventIndex()
{
let index = 0;
let ticks = Infinity;
mid.tracks.forEach((track, i) =>
{
if (eventIndexes[i] >= track.length)
{
return;
}
if (track[eventIndexes[i]].ticks < ticks)
{
index = i;
ticks = track[eventIndexes[i]].ticks;
}
});
return index;
}
// it copies midiPorts everywhere else, but here 0 works so DO NOT CHANGE!
const ports = Array(mid.tracks.length).fill(0);
const channelsAmount = 16 + mid.midiPortChannelOffsets.reduce((max, cur) => cur > max ? cur : max);
/**
* @type {{
* program: number,
* drums: boolean,
* lastBank: MIDIMessage,
* lastBankLSB: MIDIMessage,
* hasBankSelect: boolean
* }[]}
*/
const channelsInfo = [];
for (let i = 0; i < channelsAmount; i++)
{
channelsInfo.push({
program: 0,
drums: i % 16 === DEFAULT_PERCUSSION, // drums appear on 9 every 16 channels,
lastBank: undefined,
lastBankLSB: undefined,
hasBankSelect: false
});
}
while (remainingTracks > 0)
{
let trackNum = findFirstEventIndex();
const track = mid.tracks[trackNum];
if (eventIndexes[trackNum] >= track.length)
{
remainingTracks--;
continue;
}
const e = track[eventIndexes[trackNum]];
eventIndexes[trackNum]++;
let portOffset = mid.midiPortChannelOffsets[ports[trackNum]];
if (e.messageStatusByte === messageTypes.midiPort)
{
ports[trackNum] = e.messageData[0];
continue;
}
const status = e.messageStatusByte & 0xF0;
if (
status !== messageTypes.controllerChange &&
status !== messageTypes.programChange &&
status !== messageTypes.systemExclusive
)
{
continue;
}
if (status === messageTypes.systemExclusive)
{
// check for drum sysex
if (!isGSDrumsOn(e))
{
// check for XG
if (isXGOn(e))
{
system = "xg";
}
else if (isGSOn(e))
{
system = "gs";
}
else if (isGMOn(e))
{
// we do not want gm1
system = "gm";
unwantedSystems.push({
tNum: trackNum,
e: e
});
}
else if (isGM2On(e))
{
system = "gm2";
}
continue;
}
const sysexChannel = [9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15][e.messageData[5] & 0x0F] + portOffset;
channelsInfo[sysexChannel].drums = !!(e.messageData[7] > 0 && e.messageData[5] >> 4);
continue;
}
// program change
const chNum = (e.messageStatusByte & 0xF) + portOffset;
/**
* @type {{program: number, drums: boolean, lastBank: MIDIMessage, lastBankLSB: MIDIMessage, hasBankSelect: boolean}}
*/
const channel = channelsInfo[chNum];
if (status === messageTypes.programChange)
{
const isXG = isSystemXG(system);
// check if the preset for this program exists
const initialProgram = e.messageData[0];
if (channel.drums)
{
if (soundfont.presets.findIndex(p => p.program === initialProgram && p.isDrumPreset(
isXG,
true
)) === -1)
{
// doesn't exist. pick any preset that has bank 128.
e.messageData[0] = soundfont.presets.find(p => p.isDrumPreset(isXG))?.program || 0;
SpessaSynthInfo(
`%cNo drum preset %c${initialProgram}%c. Channel %c${chNum}%c. Changing program to ${e.messageData[0]}.`,
consoleColors.info,
consoleColors.unrecognized,
consoleColors.info,
consoleColors.recognized,
consoleColors.info
);
}
}
else
{
if (soundfont.presets.findIndex(p => p.program === initialProgram && !p.isDrumPreset(isXG)) === -1)
{
// doesn't exist. pick any preset that does not have bank 128.
e.messageData[0] = soundfont.presets.find(p => !p.isDrumPreset(isXG))?.program || 0;
SpessaSynthInfo(
`%cNo preset %c${initialProgram}%c. Channel %c${chNum}%c. Changing program to ${e.messageData[0]}.`,
consoleColors.info,
consoleColors.unrecognized,
consoleColors.info,
consoleColors.recognized,
consoleColors.info
);
}
}
channel.program = e.messageData[0];
// check if this preset exists for program and bank
const realBank = Math.max(0, channel.lastBank?.messageData[1] - mid.bankOffset); // make sure to take the previous bank offset into account
const bankLSB = (channel?.lastBankLSB?.messageData[1] - mid.bankOffset) || 0;
if (channel.lastBank === undefined)
{
continue;
}
// adjust bank for XG
let bank = chooseBank(realBank, bankLSB, channel.drums, isXG);
if (soundfont.presets.findIndex(p => p.bank === bank && p.program === e.messageData[0]) === -1)
{
// no preset with this bank. find this program with any bank
const targetBank = (soundfont.presets.find(p => p.program === e.messageData[0])?.bank + bankOffset) || bankOffset;
channel.lastBank.messageData[1] = targetBank;
if (channel?.lastBankLSB?.messageData)
{
channel.lastBankLSB.messageData[1] = targetBank;
}
SpessaSynthInfo(
`%cNo preset %c${bank}:${e.messageData[0]}%c. Channel %c${chNum}%c. Changing bank to ${targetBank}.`,
consoleColors.info,
consoleColors.unrecognized,
consoleColors.info,
consoleColors.recognized,
consoleColors.info
);
}
else
{
// There is a preset with this bank. Add offset. For drums add the normal offset.
let drumBank = bank;
if (isSystemXG(system) && bank === 128)
{
bank = 127;
}
const newBank = (bank === 128 ? 128 : drumBank) + bankOffset;
channel.lastBank.messageData[1] = newBank;
if (channel?.lastBankLSB?.messageData && !channel.drums)
{
channel.lastBankLSB.messageData[1] = channel.lastBankLSB.messageData[1] - mid.bankOffset + bankOffset;
}
SpessaSynthInfo(
`%cPreset %c${bank}:${e.messageData[0]}%c exists. Channel %c${chNum}%c. Changing bank to ${newBank}.`,
consoleColors.info,
consoleColors.recognized,
consoleColors.info,
consoleColors.recognized,
consoleColors.info
);
}
continue;
}
// controller change
// we only care about bank-selects
const isLSB = e.messageData[0] === midiControllers.lsbForControl0BankSelect;
if (e.messageData[0] !== midiControllers.bankSelect && !isLSB)
{
continue;
}
// bank select
channel.hasBankSelect = true;
const bankNumber = e.messageData[1];
// interpret
const intepretation = parseBankSelect(
channel?.lastBank?.messageData[1] || 0,
bankNumber,
system,
isLSB,
channel.drums,
chNum
);
if (intepretation.drumsStatus === 2)
{
channel.drums = true;
}
else if (intepretation.drumsStatus === 1)
{
channel.drums = false;
}
if (isLSB)
{
channel.lastBankLSB = e;
}
else
{
channel.lastBank = e;
}
}
// add missing bank selects
// add all bank selects that are missing for this track
channelsInfo.forEach((has, ch) =>
{
if (has.hasBankSelect === true)
{
return;
}
// find the first program change (for the given channel)
const midiChannel = ch % 16;
const status = messageTypes.programChange | midiChannel;
// find track with this channel being used
const portOffset = Math.floor(ch / 16) * 16;
const port = mid.midiPortChannelOffsets.indexOf(portOffset);
const track = mid.tracks.find((t, tNum) => mid.midiPorts[tNum] === port && mid.usedChannelsOnTrack[tNum].has(
midiChannel));
if (track === undefined)
{
// this channel is not used at all
return;
}
let indexToAdd = track.findIndex(e => e.messageStatusByte === status);
if (indexToAdd === -1)
{
// no program change...
// add programs if they are missing from the track
// (need them to activate bank 1 for the embedded sfont)
const programIndex = track.findIndex(e => (e.messageStatusByte > 0x80 && e.messageStatusByte < 0xF0) && (e.messageStatusByte & 0xF) === midiChannel);
if (programIndex === -1)
{
// no voices??? skip
return;
}
const programTicks = track[programIndex].ticks;
const targetProgram = soundfont.getPreset(0, 0).program;
track.splice(programIndex, 0, new MIDIMessage(
programTicks,
messageTypes.programChange | midiChannel,
new IndexedByteArray([targetProgram])
));
indexToAdd = programIndex;
}
SpessaSynthInfo(
`%cAdding bank select for %c${ch}`,
consoleColors.info,
consoleColors.recognized
);
const ticks = track[indexToAdd].ticks;
const targetBank = (soundfont.getPreset(
0,
has.program,
isSystemXG(system)
)?.bank + bankOffset) || bankOffset;
track.splice(indexToAdd, 0, new MIDIMessage(
ticks,
messageTypes.controllerChange | midiChannel,
new IndexedByteArray([midiControllers.bankSelect, targetBank])
));
});
// make sure to put xg if gm
if (system !== "gs" && !isSystemXG(system))
{
for (const m of unwantedSystems)
{
mid.tracks[m.tNum].splice(mid.tracks[m.tNum].indexOf(m.e), 1);
}
let index = 0;
if (mid.tracks[0][0].messageStatusByte === messageTypes.trackName)
{
index++;
}
mid.tracks[0].splice(index, 0, getGsOn(0));
}
}
const newMid = new IndexedByteArray(mid.writeMIDI().buffer);
// info data for RMID
/**
* @type {Uint8Array[]}
*/
const infoContent = [getStringBytes("INFO")];
const encoder = new TextEncoder();
// software (SpessaSynth)
infoContent.push(
writeRIFFOddSize(RMIDINFOChunks.software, encoder.encode("SpessaSynth"), true)
);
// name
if (metadata.name !== undefined)
{
infoContent.push(
writeRIFFOddSize(RMIDINFOChunks.name, encoder.encode(metadata.name), true)
);
encoding = FORCED_ENCODING;
}
else
{
infoContent.push(
writeRIFFOddSize(RMIDINFOChunks.name, mid.rawMidiName, true)
);
}
// creation date
if (metadata.creationDate !== undefined)
{
encoding = FORCED_ENCODING;
infoContent.push(
writeRIFFOddSize(RMIDINFOChunks.creationDate, encoder.encode(metadata.creationDate), true)
);
}
else
{
const today = new Date().toLocaleString(undefined, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric"
});
infoContent.push(
writeRIFFOddSize(RMIDINFOChunks.creationDate, getStringBytesZero(today), true)
);
}
// comment
if (metadata.comment !== undefined)
{
encoding = FORCED_ENCODING;
infoContent.push(
writeRIFFOddSize(RMIDINFOChunks.comment, encoder.encode(metadata.comment))
);
}
// engineer
if (metadata.engineer !== undefined)
{
infoContent.push(
writeRIFFOddSize(RMIDINFOChunks.engineer, encoder.encode(metadata.engineer), true)
);
}
// album
if (metadata.album !== undefined)
{
// note that there are two album chunks: IPRD and IALB
encoding = FORCED_ENCODING;
infoContent.push(
writeRIFFOddSize(RMIDINFOChunks.album, encoder.encode(metadata.album), true)
);
infoContent.push(
writeRIFFOddSize(RMIDINFOChunks.album2, encoder.encode(metadata.album), true)
);
}
// artist
if (metadata.artist !== undefined)
{
encoding = FORCED_ENCODING;
infoContent.push(
writeRIFFOddSize(RMIDINFOChunks.artist, encoder.encode(metadata.artist), true)
);
}
// genre
if (metadata.genre !== undefined)
{
encoding = FORCED_ENCODING;
infoContent.push(
writeRIFFOddSize(RMIDINFOChunks.genre, encoder.encode(metadata.genre), true)
);
}
// picture
if (metadata.picture !== undefined)
{
infoContent.push(
writeRIFFOddSize(RMIDINFOChunks.picture, new Uint8Array(metadata.picture))
);
}
// copyright
if (metadata.copyright !== undefined)
{
encoding = FORCED_ENCODING;
infoContent.push(
writeRIFFOddSize(RMIDINFOChunks.copyright, encoder.encode(metadata.copyright), true)
);
}
else
{
// use midi copyright if possible
const copyright = mid.copyright.length > 0 ? mid.copyright : DEFAULT_COPYRIGHT;
infoContent.push(
writeRIFFOddSize(RMIDINFOChunks.copyright, getStringBytesZero(copyright))
);
}
// bank offset
const DBNK = new IndexedByteArray(2);
writeLittleEndian(DBNK, bankOffset, 2);
infoContent.push(writeRIFFOddSize(RMIDINFOChunks.bankOffset, DBNK));
// midi encoding
if (metadata.midiEncoding !== undefined)
{
infoContent.push(
writeRIFFOddSize(RMIDINFOChunks.midiEncoding, encoder.encode(metadata.midiEncoding))
);
encoding = FORCED_ENCODING;
}
// encoding
infoContent.push(writeRIFFOddSize(RMIDINFOChunks.encoding, getStringBytesZero(encoding)));
// combine and write out
const infodata = combineArrays(infoContent);
const rmiddata = combineArrays([
getStringBytes("RMID"),
writeRIFFOddSize(
"data",
newMid
),
writeRIFFOddSize(
"LIST",
infodata
),
soundfontBinary
]);
SpessaSynthInfo("%cFinished!", consoleColors.info);
SpessaSynthGroupEnd();
return writeRIFFOddSize(
"RIFF",
rmiddata
);
}