singe/src/midi.c

227 lines
7.3 KiB
C

/*
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* 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, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*/
// Singe: MIDI through TinySoundFont. SDL_mixer's own MIDI decoder is TiMidity, which wants a GUS
// patch set nobody installs any more; this reads a SoundFont instead, which is what a person who
// wants MIDI to sound like something will already have. There is no MIDI hardware here in either
// direction: a file is synthesised to samples at load, and that is all.
#include <string.h>
#include <SDL3/SDL.h>
#include "common.h"
#include "decode.h"
#include "midi.h"
#include "util.h"
#include "vfs.h"
#define TSF_IMPLEMENTATION
#include "../thirdparty/tinysoundfont/tsf.h"
#define TML_IMPLEMENTATION
#include "../thirdparty/tinysoundfont/tml.h"
#define MIDI_SAMPLE_RATE 44100
#define MS_PER_SECOND 1000
#define MIDI_CHANNELS 2
#define MIDI_BLOCK_SAMPLES 512 // Messages land on a block boundary, 11.6 ms apart
#define MIDI_TAIL_MS 3000 // Rendered past the last message so releases finish
#define MIDI_MAX_MS (20 * 60 * 1000) // A MIDI longer than this is a mistake, not music
#define MIDI_MAX_VOICES 64
#define MIDI_HEADER_BYTES 4
#define MIDI_DRUM_CHANNEL 9
#define MIDI_VELOCITY_MAX 127.0f
#define MIDI_GAIN_DB (-3.0f) // A little headroom: many banks clip at full scale
static const char *_paths[] = {
// The support folder the engine extracts, so a game can carry its own bank, and a bare file
// beside the game for someone who dropped one in.
"Singe/soundfont.sf2",
"soundfont.sf2",
// Where the distributions put the banks their own MIDI players use.
"/usr/share/soundfonts/default.sf2",
"/usr/share/soundfonts/FluidR3_GM.sf2",
"/usr/share/sounds/sf2/default-GM.sf2",
"/usr/share/sounds/sf2/FluidR3_GM.sf2",
"/usr/share/sounds/sf2/TimGM6mb.sf2"
};
static tsf *_soundfont = NULL;
static char *_found = NULL;
static void _apply(tml_message *message);
static bool _load(const char *path);
// ===== Internal helpers =====
// One MIDI message into the synthesiser. Anything else in the file is nothing a synthesiser acts on.
static void _apply(tml_message *message) {
switch (message->type) {
case TML_PROGRAM_CHANGE:
tsf_channel_set_presetnumber(_soundfont, message->channel, message->program, (message->channel == MIDI_DRUM_CHANNEL));
break;
case TML_NOTE_ON:
tsf_channel_note_on(_soundfont, message->channel, message->key, (float)message->velocity / MIDI_VELOCITY_MAX);
break;
case TML_NOTE_OFF:
tsf_channel_note_off(_soundfont, message->channel, message->key);
break;
case TML_PITCH_BEND:
tsf_channel_set_pitchwheel(_soundfont, message->channel, message->pitch_bend);
break;
case TML_CONTROL_CHANGE:
tsf_channel_midi_control(_soundfont, message->channel, message->control, message->control_value);
break;
default:
break;
}
}
// Reads one candidate. The vfs is asked first, so a packed game's own bank is found and a name is
// resolved the way every other name in a game is; the file system is the fallback, which is what
// finds the bank a distribution installed somewhere absolute. Asking in that order rather than
// looking at the shape of the path keeps it working on Windows, where an absolute path does not
// begin with a slash.
static bool _load(const char *path) {
size_t size = 0;
char *bytes = vfsRead(path, &size);
if (bytes != NULL) {
if (size <= INT32_MAX) {
_soundfont = tsf_load_memory(bytes, (int32_t)size);
}
free(bytes);
} else {
_soundfont = tsf_load_filename(path);
}
if (_soundfont == NULL) {
return false;
}
tsf_set_max_voices(_soundfont, MIDI_MAX_VOICES);
tsf_set_output(_soundfont, TSF_STEREO_INTERLEAVED, MIDI_SAMPLE_RATE, MIDI_GAIN_DB);
_found = utilCreateString("%s", path);
return true;
}
// ===== Public =====
void midiInit(const char *wanted) {
int32_t x;
if (wanted != NULL) {
if (!_load(wanted)) {
utilTrace("No MIDI sound bank: %s could not be read as a SoundFont.", wanted);
}
return;
}
for (x = 0; x < (int32_t)(sizeof(_paths) / sizeof(_paths[0])); x++) {
if (_load(_paths[x])) {
utilTrace("MIDI sound bank: %s, %d presets.", _found, tsf_get_presetcount(_soundfont));
return;
}
}
}
bool midiIs(const void *bytes, size_t size) {
return (size > MIDI_HEADER_BYTES) && (memcmp(bytes, "MThd", MIDI_HEADER_BYTES) == 0);
}
bool midiRender(const void *bytes, size_t size, DecodedAudioT *out) {
tml_message *messages = NULL;
tml_message *message = NULL;
float *pcm = NULL;
uint32_t length = 0;
int64_t total = 0;
int64_t done = 0;
int32_t block = 0;
double played = 0.0;
double perBlock = (double)MIDI_BLOCK_SAMPLES * MS_PER_SECOND / MIDI_SAMPLE_RATE;
memset(out, 0, sizeof(*out));
if (_soundfont == NULL) {
SDL_SetError("This is a MIDI file, and no SoundFont was found to play it with. Name one with --soundfont.");
return false;
}
if (size > INT32_MAX) {
SDL_SetError("The MIDI file is too large.");
return false;
}
messages = tml_load_memory(bytes, (int32_t)size);
if (messages == NULL) {
SDL_SetError("The MIDI file would not parse.");
return false;
}
tml_get_info(messages, NULL, NULL, NULL, NULL, &length);
if ((length + MIDI_TAIL_MS) > MIDI_MAX_MS) {
length = MIDI_MAX_MS - MIDI_TAIL_MS;
}
// Whole blocks, so the render loop never has a partial one to think about.
total = (((int64_t)length + MIDI_TAIL_MS) * MIDI_SAMPLE_RATE / MS_PER_SECOND + MIDI_BLOCK_SAMPLES - 1) / MIDI_BLOCK_SAMPLES * MIDI_BLOCK_SAMPLES;
pcm = (float *)SDL_malloc((size_t)total * MIDI_CHANNELS * sizeof(float));
if (pcm == NULL) {
SDL_SetError("Out of memory rendering a MIDI file.");
tml_free(messages);
return false;
}
tsf_reset(_soundfont);
message = messages;
while (done < total) {
block = (int32_t)((total - done > MIDI_BLOCK_SAMPLES) ? MIDI_BLOCK_SAMPLES : (total - done));
played += perBlock;
while ((message != NULL) && (message->time <= (uint32_t)played)) {
_apply(message);
message = message->next;
}
tsf_render_float(_soundfont, pcm + done * MIDI_CHANNELS, block, 0);
done += block;
}
tml_free(messages);
out->pcm = (uint8_t *)pcm;
out->bytes = (size_t)total * MIDI_CHANNELS * sizeof(float);
out->spec.format = SDL_AUDIO_F32;
out->spec.channels = MIDI_CHANNELS;
out->spec.freq = MIDI_SAMPLE_RATE;
return true;
}
void midiQuit(void) {
if (_soundfont != NULL) {
tsf_close(_soundfont);
_soundfont = NULL;
}
free(_found);
_found = NULL;
}
const char *midiSoundfont(void) {
return (_found != NULL) ? _found : "none found (MIDI will not play; name one with --soundfont)";
}