MIDI to WAV
Goal: render a Standard MIDI File through a SoundFont bank into a WAV file. Prepare the bank first as described in SoundFont banks.
Both examples convert the song into
MIDI events with sample frame
positions, then stream silence through SoundFontPlayer one second at a time and
hand each block the events that fall inside it. Three seconds of silence after the
last event let the final notes release. Each block stays well under the limit of
4096 MIDI events per effect in each blockSize (Python: block_size) frame segment;
for a very dense song, use a smaller block size. The player is set to -6 dB
(outputGain) for headroom because the WAV is written as 16-bit integers; lower
outputGain further if the render clips.
The Python example reads the song with mido
(pip install effetune mido) and runs as
python midi_to_wav.py song.mid piano.etsf song.wav:
import sys
import wave
import mido
import numpy as np
import effetune as et
SAMPLE_RATE = 48_000
BLOCK_FRAMES = SAMPLE_RATE # one second per process() call
TAIL_SECONDS = 3 # silence after the last event lets releases and reverb finish
SUPPORTED = ("note_on", "note_off", "control_change", "pitchwheel", "program_change")
song_path, bank_path, wav_path = sys.argv[1:4]
# mido yields every track merged in time order, with message.time in seconds.
events = []
seconds = 0.0
for message in mido.MidiFile(song_path):
seconds += message.time
if message.type in SUPPORTED:
data = message.bytes()
events.append((round(seconds * SAMPLE_RATE), data + [0] * (3 - len(data))))
total_frames = (events[-1][0] if events else 0) + TAIL_SECONDS * SAMPLE_RATE
bank = et.SoundFontBank.load(bank_path)
chain = et.Chain(
[et.SoundFontPlayer(id="player", output_gain=-6, assets={"soundFont": "bank"})],
asset_resolver=lambda _reference: bank,
)
blocks = []
with chain.stream(SAMPLE_RATE, channels=2) as stream:
index = 0
for start in range(0, total_frames, BLOCK_FRAMES):
frames = min(BLOCK_FRAMES, total_frames - start)
block_events = []
while index < len(events) and events[index][0] < start + frames:
frame, data = events[index]
block_events.append({"frame": frame - start, "effectId": "player", "midi": data})
index += 1
silence = np.zeros((2, frames), dtype=np.float32)
blocks.append(stream.process(silence, events=block_events))
audio = np.concatenate(blocks, axis=1)
pcm = np.round(np.clip(audio, -1, 1) * 32767).astype("<i2")
with wave.open(wav_path, "wb") as output:
output.setnchannels(2)
output.setsampwidth(2)
output.setframerate(SAMPLE_RATE)
output.writeframes(pcm.T.tobytes())
The JavaScript example includes a small SMF reader; any MIDI parser that yields the
same frame and byte pairs works. Run it as
node midi-to-wav.mjs song.mid piano.etsf song.wav:
import fs from 'node:fs';
import { createChain, SoundFontBank } from '@effetune/dsp';
const sampleRate = 48000;
const blockFrames = sampleRate; // one second per process() call
const tailSeconds = 3; // silence after the last event lets releases and reverb finish
const [songPath, bankPath, wavPath] = process.argv.slice(2);
// A minimal Standard MIDI File reader that returns [frame, [status, data1, data2]]
// for the supported channel messages. Any SMF parser that yields the same pairs works.
function readMidiEvents(bytes) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const division = view.getUint16(12);
if (division & 0x8000) throw new Error('SMPTE time division is not supported');
const timed = [];
for (let offset = 8 + view.getUint32(4); offset < bytes.length;) {
const end = offset + 8 + view.getUint32(offset + 4);
let position = offset + 8;
let tick = 0;
let status = 0;
const readLength = () => {
let value = 0;
let byte;
do {
byte = bytes[position++];
value = value * 128 + (byte & 127);
} while (byte & 128);
return value;
};
const track = String.fromCharCode(...bytes.subarray(offset, offset + 4)) === 'MTrk';
while (track && position < end) {
tick += readLength();
if (bytes[position] & 0x80) status = bytes[position++];
if (status === 0xff) {
const type = bytes[position++];
const length = readLength();
if (type === 0x51) {
const tempo = bytes[position] << 16 | bytes[position + 1] << 8 | bytes[position + 2];
timed.push({ tick, tempo });
}
position += length;
} else if (status === 0xf0 || status === 0xf7) {
const length = readLength();
position += length;
} else {
const size = (status & 0xe0) === 0xc0 ? 1 : 2;
const kind = status & 0xf0;
if (kind !== 0xa0 && kind !== 0xd0) {
timed.push({ tick, midi: [status, bytes[position], size === 2 ? bytes[position + 1] : 0] });
}
position += size;
}
}
offset = end;
}
timed.sort((a, b) => a.tick - b.tick);
let tempo = 500000;
let seconds = 0;
let lastTick = 0;
const events = [];
for (const event of timed) {
seconds += (event.tick - lastTick) * tempo / (division * 1e6);
lastTick = event.tick;
if (event.tempo) tempo = event.tempo;
else events.push([Math.round(seconds * sampleRate), event.midi]);
}
return events;
}
function encodeWav16(channels) {
const frames = channels[0].length;
const bytes = Buffer.alloc(44 + frames * channels.length * 2);
bytes.write('RIFF', 0);
bytes.writeUInt32LE(bytes.length - 8, 4);
bytes.write('WAVEfmt ', 8);
bytes.writeUInt32LE(16, 16);
bytes.writeUInt16LE(1, 20);
bytes.writeUInt16LE(channels.length, 22);
bytes.writeUInt32LE(sampleRate, 24);
bytes.writeUInt32LE(sampleRate * channels.length * 2, 28);
bytes.writeUInt16LE(channels.length * 2, 32);
bytes.writeUInt16LE(16, 34);
bytes.write('data', 36);
bytes.writeUInt32LE(frames * channels.length * 2, 40);
let offset = 44;
for (let frame = 0; frame < frames; frame += 1) {
for (const channel of channels) {
const sample = channel[frame] > 1 ? 1 : channel[frame] < -1 ? -1 : channel[frame];
bytes.writeInt16LE(Math.round(sample * 32767), offset);
offset += 2;
}
}
return bytes;
}
const events = readMidiEvents(fs.readFileSync(songPath));
const totalFrames = (events.at(-1)?.[0] ?? 0) + tailSeconds * sampleRate;
const bank = SoundFontBank.fromBytes(fs.readFileSync(bankPath));
const chain = await createChain({
version: 1,
chain: [{ id: 'player', type: 'SoundFontPlayer', parameters: { outputGain: -6 }, assets: { soundFont: 'bank' } }]
}, { assetResolver: () => bank });
const stream = await chain.stream({ sampleRate, channels: 2 });
const audio = [new Float32Array(totalFrames), new Float32Array(totalFrames)];
try {
let index = 0;
for (let start = 0; start < totalFrames; start += blockFrames) {
const frames = Math.min(blockFrames, totalFrames - start);
const blockEvents = [];
while (index < events.length && events[index][0] < start + frames) {
const [frame, midi] = events[index++];
blockEvents.push({ frame: frame - start, effectId: 'player', midi });
}
const silence = [new Float32Array(frames), new Float32Array(frames)];
const output = await stream.process(silence, { events: blockEvents });
output.forEach((channel, channelIndex) => audio[channelIndex].set(channel, start));
}
} finally {
stream.close();
chain.close();
}
fs.writeFileSync(wavPath, encodeWav16(audio));