Multimedia — MIDI, MCI & Timers
Free API implements MIDI music playback via TinySoundFont + TinyMidiLoader over an SDL3 audio stream, the MCI command API for sequencer devices, multimedia periodic timers, and MIDI output volume control.
mmsystem.h is included automatically by <windows.h>.
You only need #include <mmsystem.h> explicitly if you are not
including <windows.h>.
MIDI playback architecture
Game calls mciSendCommand(MCI_OPEN) │ ├─ TinyMidiLoader parses .mid file (Type 0 or Type 1) ├─ TinySoundFont loads .sf2 SoundFont for synthesis └─ SDL3 audio device opened (one shared device for all tracks) Game calls mciSendCommand(MCI_PLAY) │ ├─ Mixer thread starts │ ├─ advances MIDI time tick by tick │ ├─ sends Note On/Off, Program Change, Control Change to TinySoundFont │ └─ TinySoundFont synthesises stereo float PCM └─ SDL3 audio stream feeds PCM to OS audio driver Song ends → MM_MCINOTIFY posted to notification window Game calls mciSendCommand(MCI_CLOSE) └─ stops mixer thread, frees MIDI data
Vendored libraries
| Library | File | Version | Purpose |
|---|---|---|---|
| TinySoundFont | external/tsf.h | v0.9 | SoundFont 2 synthesiser — converts MIDI events to PCM audio. MIT license by Bernhard Schelling. |
| TinyMidiLoader | external/tml.h | v0.7 | MIDI file parser (Type 0 and Type 1). MIT license by Bernhard Schelling. |
SoundFont 2 (.sf2) requirement
MIDI synthesis requires a SoundFont 2 file that maps MIDI instrument numbers to audio samples. No SoundFont is bundled with Free API (copyright reasons).
Free API searches for a SoundFont in this order (first found wins):
- Environment variable:
FREE_API_SOUNDFONT=/path/to/file.sf2 - Relative path:
assets/soundfont/default.sf2 - Relative path:
soundfont/default.sf2
If no SoundFont is found, MCI_OPEN still succeeds but playback is
silent. A warning is printed to the SDL log:
[midi] WARNING: No SoundFont found. Music will be silent. Set FREE_API_SOUNDFONT=/path/to/file.sf2 or place default.sf2 in assets/soundfont/ or soundfont/.
FluidR3_GM — Debian/Ubuntu:
sudo apt install fluid-soundfont-gmPath on Debian:
/usr/share/sounds/sf2/FluidR3_GM.sf2
# One-shot FREE_API_SOUNDFONT=/usr/share/sounds/sf2/FluidR3_GM.sf2 ./MyGame # Or export for the session export FREE_API_SOUNDFONT=/usr/share/sounds/sf2/FluidR3_GM.sf2 ./MyGame
// Must be called before the first MCI_OPEN for it to take effect SetEnvironmentVariableA("FREE_API_SOUNDFONT", "/usr/share/sounds/sf2/FluidR3_GM.sf2");
MCI types and result codes
| Type | Underlying | Status | Description |
|---|---|---|---|
MMRESULT | UINT | IMPLEMENTED | Return type for WinMM API functions. |
MCIDEVICEID | UINT | PARTIAL | Identifies an open MCI device (sequencer). Assigned by MCI_OPEN, freed by MCI_CLOSE. |
MCIERROR | DWORD | PARTIAL | Error code returned by mciSendCommandA. |
HMIDIOUT | HANDLE | STUB | Opaque MIDI output device handle. Real audio goes through MCI; this is a dummy wrapper for volume control. |
LPTIMECALLBACK | function pointer | PARTIAL | Type of callback passed to timeSetEvent. Signature: void CALLBACK f(UINT, UINT, DWORD_PTR, DWORD_PTR, DWORD_PTR). |
Result code constants
| Constant | Value | Meaning |
|---|---|---|
MMSYSERR_NOERROR | 0 | Success. |
MCIERR_INVALID_DEVICE_ID | 259 | The MCI device ID is not valid. |
MCIERR_UNSUPPORTED_FUNCTION | 268 | The device type or command is not supported (e.g. cdaudio). |
MCIERR_INTERNAL | 305 | Internal Free API error (e.g. TinyMidiLoader parse failure). |
MCI parameter structures
typedef struct _MCI_OPEN_PARMSA { DWORD_PTR dwCallback; // notification window (ignored for MCI_OPEN) MCIDEVICEID wDeviceID; // filled by Free API on MCI_OPEN success LPCSTR lpstrDeviceType; // "sequencer" for MIDI files LPCSTR lpstrElementName;// path to the MIDI file LPCSTR lpstrAlias; // optional alias (ignored) } MCI_OPEN_PARMSA; typedef MCI_OPEN_PARMSA MCI_OPEN_PARMS;
typedef struct _MCI_PLAY_PARMS { DWORD_PTR dwCallback; // HWND to receive MM_MCINOTIFY when song ends DWORD_PTR dwFrom; // start position (ignored — always starts from 0) DWORD_PTR dwTo; // end position (ignored) } MCI_PLAY_PARMS;
typedef struct _MCI_SET_PARMS { DWORD_PTR dwCallback; // ignored DWORD dwTimeFormat; // ignored (no-op) DWORD dwAudio; // ignored } MCI_SET_PARMS;
MCI_OPEN — open a MIDI file
MCIERROR WINAPI mciSendCommandA(
MCIDEVICEID mciId, // 0 for MCI_OPEN
UINT uMsg, // MCI_OPEN
DWORD_PTR fdwCommand, // flags: MCI_OPEN_TYPE | MCI_OPEN_ELEMENT
DWORD_PTR dwParam // pointer to MCI_OPEN_PARMS
);
// #define mciSendCommand mciSendCommandA
MCI_OPEN with lpstrDeviceType = "sequencer" loads the MIDI file
specified by lpstrElementName into memory via TinyMidiLoader.
On success, op.wDeviceID is filled with a non-zero ID that must be used
in subsequent MCI_PLAY and MCI_CLOSE calls.
Specifying lpstrDeviceType = "cdaudio" returns
MCIERR_UNSUPPORTED_FUNCTION gracefully — it will not crash.
| Flag | Value | Meaning |
|---|---|---|
MCI_OPEN_TYPE | 0x00002000 | lpstrDeviceType is a string (required for "sequencer"). |
MCI_OPEN_TYPE_ID | 0x00001000 | lpstrDeviceType is an integer ID (alternative form, not used by Blupi). |
MCI_OPEN_ELEMENT | 0x00000200 | lpstrElementName is a file path. |
#include <windows.h> // includes mmsystem.h MCIDEVICEID g_musicId = 0; BOOL OpenMidiFile(const char* path) { // Close any previously open track if (g_musicId) { mciSendCommand(g_musicId, MCI_CLOSE, 0, 0); g_musicId = 0; } MCI_OPEN_PARMS op = {}; op.lpstrDeviceType = "sequencer"; op.lpstrElementName = path; MCIERROR err = mciSendCommand(0, MCI_OPEN, MCI_OPEN_TYPE | MCI_OPEN_ELEMENT, (DWORD_PTR)&op); if (err != MMSYSERR_NOERROR) { char msg[256]; mciGetErrorString(err, msg, sizeof(msg)); OutputDebugString(msg); return FALSE; } g_musicId = op.wDeviceID; return TRUE; }
MCI_PLAY — start playback
Starts the SDL3 audio stream and launches the mixer thread. Playback runs
asynchronously — mciSendCommand returns immediately.
When the song ends, MM_MCINOTIFY is posted to the window specified
in dwCallback.
| Flag | Value | Meaning |
|---|---|---|
MCI_NOTIFY | 0x00000001 | Post MM_MCINOTIFY to op.dwCallback (HWND) when song ends. Recommended. |
MCI_WAIT | 0x00000002 | Wait for operation to complete — NOT IMPLEMENTED. Behaves like MCI_NOTIFY. |
void PlayMusic(HWND hWndNotify) { MCI_PLAY_PARMS pp = {}; pp.dwCallback = (DWORD_PTR)hWndNotify; mciSendCommand(g_musicId, MCI_PLAY, MCI_NOTIFY, (DWORD_PTR)&pp); } // In WNDPROC — handle end-of-song notification: case MM_MCINOTIFY: if (wParam == MCI_NOTIFY_SUCCESSFUL && g_musicId) { // Song finished — restart for looping MCI_PLAY_PARMS pp = { (DWORD_PTR)hWnd }; mciSendCommand(g_musicId, MCI_PLAY, MCI_NOTIFY, (DWORD_PTR)&pp); } return 0;
MCI_CLOSE — stop and free
void StopMusic() { if (g_musicId) { mciSendCommand(g_musicId, MCI_CLOSE, 0, 0); g_musicId = 0; } } // Always close in WM_DESTROY: case WM_DESTROY: StopMusic(); PostQuitMessage(0); return 0;
All MCI constants
| Constant | Value | Description |
|---|---|---|
MCI_OPEN | 0x0803 | Open a device. |
MCI_CLOSE | 0x0804 | Close a device. |
MCI_PLAY | 0x0806 | Start playback. |
MCI_SET | 0x080D | Set device parameters (accepted as no-op). |
MCI_NOTIFY | 0x00000001L | Post notification when done. |
MCI_WAIT | 0x00000002L | Wait flag (not implemented). |
MCI_OPEN_TYPE | 0x00002000L | lpstrDeviceType is a string. |
MCI_OPEN_TYPE_ID | 0x00001000L | lpstrDeviceType is an ID. |
MCI_OPEN_ELEMENT | 0x00000200L | lpstrElementName is a file path. |
MCI_SET_TIME_FORMAT | 0x00000400L | Set time format (no-op). |
MCI_FORMAT_TMSF | 10 | Track/Minute/Second/Frame format constant. |
MCI_TRACK | 0x00000010L | Track flag for position. |
MM_MCINOTIFY | 0x03B9 | Message posted by MCI to the notification window when an operation completes. |
MCI_NOTIFY_SUCCESSFUL | 0x0001 | wParam value in MM_MCINOTIFY when operation succeeded. |
MIDI output — midiOutOpen, midiOutSetVolume, midiOutClose
The MIDI output API is used primarily to control playback volume. Real note output is not implemented — use MCI for actual playback.
| Function | Status | Description |
|---|---|---|
midiOutGetNumDevs(void) |
IMPLEMENTED | Returns 1 if the SDL3 audio subsystem is available, 0 otherwise. Games use this to check whether any MIDI device is present before calling midiOutOpen. |
midiOutOpen(phmo, uDeviceID, dwCallback, dwInstance, fdwOpen) |
IMPLEMENTED | Allocates a dummy handle and writes it to *phmo. Accepts any uDeviceID (typically 0 or MIDI_MAPPER). Returns MMSYSERR_NOERROR on success. |
midiOutSetVolume(hmo, dwVolume) |
IMPLEMENTED | Sets playback volume. dwVolume is a packed 32-bit value: low 16 bits = left channel, high 16 bits = right channel (range 0x0000–0xFFFF). The average of both channels is mapped to a 0.0–1.0 linear gain applied to TinySoundFont. |
midiOutClose(hmo) |
IMPLEMENTED | Releases the dummy handle. Returns MMSYSERR_NOERROR. |
void SetMusicVolume(int percent) // 0-100 { if (midiOutGetNumDevs() == 0) return; HMIDIOUT hmo; if (midiOutOpen(&hmo, 0, 0, 0, 0) != MMSYSERR_NOERROR) return; // Scale 0-100% to 0x0000-0xFFFF WORD vol = (WORD)((percent * 0xFFFF) / 100); DWORD packed = (DWORD)vol | ((DWORD)vol << 16); // same for L and R midiOutSetVolume(hmo, packed); midiOutClose(hmo); } // Examples: SetMusicVolume(100); // full volume SetMusicVolume(50); // half volume SetMusicVolume(0); // muted
mciGetErrorString — human-readable error text
BOOL WINAPI mciGetErrorStringA(MCIERROR mcierr, LPSTR pszText, UINT cchText);
// #define mciGetErrorString mciGetErrorStringA
MCIERROR err = mciSendCommand(0, MCI_OPEN,
MCI_OPEN_TYPE | MCI_OPEN_ELEMENT,
(DWORD_PTR)&op);
if (err != MMSYSERR_NOERROR) {
char errorMsg[256];
mciGetErrorString(err, errorMsg, sizeof(errorMsg));
char fullMsg[512];
snprintf(fullMsg, sizeof(fullMsg),
"MCI error %lu: %s\n", (unsigned long)err, errorMsg);
OutputDebugString(fullMsg);
return FALSE;
}
timeSetEvent / timeKillEvent — high-frequency periodic timers
Multimedia timers fire a callback on a private SDL timer thread at precise intervals — independent of the message loop. This is the mechanism Speedy Blupi and Planet Blupi use for their main game tick.
MMRESULT WINAPI timeSetEvent(
UINT uDelay, // interval in milliseconds (minimum ~1 ms)
UINT uResolution, // timer resolution request (0 = as accurate as possible)
LPTIMECALLBACK lpTimeProc, // function called at each interval
DWORD_PTR dwUser, // user data passed to callback
UINT fuEvent // must be TIME_PERIODIC
);
// Returns timer ID (non-zero) on success, 0 on failure.
MMRESULT WINAPI timeKillEvent(UINT uTimerID);
// Returns MMSYSERR_NOERROR on success.
Callback signature
void CALLBACK TimerCallback( UINT uTimerID, // same ID returned by timeSetEvent UINT uMsg, // reserved (0) DWORD_PTR dwUser, // your dwUser value DWORD_PTR dw1, // reserved (0) DWORD_PTR dw2 // reserved (0) );
PostMessage from the callback is safe. Do not call other
Free API functions from the callback unless you have verified they are thread-safe.
#include <windows.h> UINT g_timerId = 0; // Callback fires every 50 ms on a private SDL timer thread void CALLBACK GameTimerCallback(UINT id, UINT msg, DWORD_PTR user, DWORD_PTR dw1, DWORD_PTR dw2) { HWND hWnd = (HWND)user; // Safe to PostMessage from a timer thread PostMessage(hWnd, WM_USER + 1, 0, 0); } void StartGameLoop(HWND hWnd) { g_timerId = timeSetEvent( 50, // 50 ms = 20 ticks/second 1, // 1 ms resolution GameTimerCallback, (DWORD_PTR)hWnd, TIME_PERIODIC ); if (!g_timerId) OutputDebugString("timeSetEvent failed\n"); } void StopGameLoop() { if (g_timerId) { timeKillEvent(g_timerId); g_timerId = 0; } } // In WNDPROC — handle the tick message posted by the callback: case WM_USER + 1: GameUpdate(); // runs on the main (message loop) thread GameRender(); return 0;
| Constant | Value | Description |
|---|---|---|
TIME_PERIODIC | 0x0001 | Required flag — the timer fires repeatedly at the given interval. |
Joystick API (stubs)
Joystick support is not implemented. The functions compile cleanly and return safe values so legacy code that probes for a joystick does not crash.
| Function | Status | Returns |
|---|---|---|
joyGetNumDevs(void) | STUB | 0 — no joystick devices. |
joyGetPosEx(uJoyID, JOYINFOEX*) | STUB | MMSYSERR_NOERROR with zeroed JOYINFOEX struct. |
MIDI debug logging
FREE_API_DEBUG_MIDI=1 ./MyGame
With FREE_API_DEBUG_MIDI=1 set, Free API logs every MCI / midiOut
call and all mixer-thread events (note on/off, program changes, timing) to the
SDL log. Useful for diagnosing why music does not play or sounds wrong.