227 lines
7.3 KiB
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)";
|
|
}
|