midi = {} local modname = minetest.get_current_modname() local modpath = minetest.get_modpath(modname) ---------------------------------------- -- Registration function ---------------------------------------- midi.registered_instruments = {} function midi.register_instrument(program_number, def) midi.registered_instruments[program_number] = { description = def.description, get_sounds = def.get_sounds, } end ---------------------------------------- -- Helper ---------------------------------------- function midi.get_scale(note_number) local scales = { "C-1", "C#-1", "D-1", "D#-1", "E-1", "F-1", "F#-1", "G-1", "G#-1", "A-1", "A#-1", "B-1", "C0", "C#0", "D0", "D#0", "E0", "F0", "F#0", "G0", "G#0", "A0", "A#0", "B0", "C1", "C#1", "D1", "D#1", "E1", "F1", "F#1", "G1", "G#1", "A1", "A#1", "B1", "C2", "C#2", "D2", "D#2", "E2", "F2", "F#2", "G2", "G#2", "A2", "A#2", "B2", "C3", "C#3", "D3", "D#3", "E3", "F3", "F#3", "G3", "G#3", "A3", "A#3", "B3", "C4", "C#4", "D4", "D#4", "E4", "F4", "F#4", "G4", "G#4", "A4", "A#4", "B4", "C5", "C#5", "D5", "D#5", "E5", "F5", "F#5", "G5", "G#5", "A5", "A#5", "B5", "C6", "C#6", "D6", "D#6", "E6", "F6", "F#6", "G6", "G#6", "A6", "A#6", "B6", "C7", "C#7", "D7", "D#7", "E7", "F7", "F#7", "G7", "G#7", "A7", "A#7", "B7", "C8", "C#8", "D8", "D#8", "E8", "F8", "F#8", "G8", "G#8", "A8", "A#8", "B8", "C9", "C#9", "D9", "D#9", "E9", "F9", "F#9", "G9", "G#9", "A9", "A#9", "B9" } local scale = scales[note_number + 1] -- Note number starts at 0, but Lua table index starts at 1. return scale or "None" end ---------------------------------------- -- Loading function ---------------------------------------- -- Merge tracks to single track local function merge_tracks(tracks) local track_merged, timings = {}, {} for _, track in ipairs(tracks) do local time = 0 -- Current time local program = 1 -- Instrument number for _, message in ipairs(track.messages) do time = time + message.time -- Initialize notes if not track_merged[time] then track_merged[time] = { notes = {}, tempo_change = nil -- For 'time_to_seconds' function } table.insert(timings, time) end if (message.type == "on") or (message.type == "off") then -- Note on/off local note = { type = message.type, number = message.number, velocity = message.velocity or 80, program = program } table.insert(track_merged[time].notes, note) elseif (message.type == "meta") and message.tempo then -- Change tempo track_merged[time].tempo_change = message.tempo elseif (message.type == "program_change") then -- Change program(instrument) program = message.program end end end table.sort(timings) return track_merged, timings end -- Convert track to table that has seconds as key local function time_to_seconds(track, timings, timebase) local track_converted = {} local tempo = 0.5 -- Dummy local seconds = 0 for i, time in ipairs(timings) do local data = track[time] -- Change tempo if data.tempo_change then tempo = (data.tempo_change / 1000000) -- Micro seconds to seconds end -- Convert time to seconds local difftime = timings[i] - (timings[i - 1] or 0) seconds = seconds + (difftime / timebase * tempo) for _, note in ipairs(data.notes) do if (note.type == "on") or (note.type == "off") then if not track_converted[seconds] then track_converted[seconds] = {} end table.insert(track_converted[seconds], note) end end end return track_converted end local parser = dofile(modpath .. "/lib/parser.lua") function midi.load_midi(midi_path) local midi_parsed = parser(midi_path) local tracks = midi_parsed.tracks local track_merged, timings = merge_tracks(tracks) local track = time_to_seconds(track_merged, timings, midi_parsed.timebase) return track end ---------------------------------------- -- Playing function ---------------------------------------- function midi.play_midi(name, track, delay) local function note_off(note) local fade = [[ if handles[%d] then for i = 1, #handles[%d] do fade(handles[%d][i], %f, 0) end handles[%d] = nil end ]] local step = ((note.velocity ~= 0 and note.velocity or 50) / -10) return fade:format(note.number, note.number, note.number, step, note.number) end local function note_on(note) local create_handle_list = [[ if not handles[%d] then handles[%d] = {} end ]] local play = [[ handles[%d][#handles[%d] + 1] = play("%s", { gain = %f, pitch = %f, to_player = "%s" }) ]] -- Get sounds local instrument = midi.registered_instruments[note.program] if not instrument then return "" end local sounds = instrument.get_sounds(table.copy(note)) if (#sounds == 0) then return "" end local func = "" for _, sound in ipairs(sounds) do if (sound.gain > 0) and (sound.pitch > 0) then func = func .. " " .. play:format(note.number, note.number, sound.name, sound.gain, sound.pitch, name) end end local is_func_empty = (func:gsub(" ", "") == "") if is_func_empty then return "" end return create_handle_list:format(note.number, note.number) .. func end local function note_release(note) local play = [[ play("%s", { gain = %f, pitch = %f, to_player = "%s" }) ]] -- Get sounds local instrument = midi.registered_instruments[note.program] if not instrument then return "" end local sounds = instrument.get_sounds(table.copy(note)) if (#sounds == 0) then return "" end local func = "" for _, sound in ipairs(sounds) do if (sound.gain > 0) and (sound.pitch > 0) then func = func .. " " .. play:format(sound.name, sound.gain, sound.pitch, name) end end return func end local handles = {} for seconds, notes in pairs(track) do local function_string = "" for _, note in ipairs(notes) do if (note.velocity == 0) then note.type = "off" end if (note.type == "on") then function_string = function_string .. " " .. note_on(note) elseif (note.type == "off") then function_string = note_off(note) .. " " .. function_string function_string = function_string .. " " .. note_release(note) end end local is_func_empty = (function_string:gsub(" ", "") == "") if not is_func_empty then local func = [[ return function(handles, play, fade) %s end ]] minetest.after((seconds + delay), loadstring(func:format(function_string))(), handles, minetest.sound_play, minetest.sound_fade) end end end ---------------------------------------- -- Chatcommand ---------------------------------------- minetest.register_chatcommand("midi", { description = "Play midi", params = " [delay]", func = function(name, param) local params = param:split(" ") local midi_name = params[1] if not midi_name then return false, "midiname required" end local flag, ret = pcall(function() local midi_path = (modpath .. "/midi/" .. midi_name) return midi.load_midi(midi_path) end) if not flag then return false, ret end local delay = tonumber(params[2]) or 1 midi.play_midi(name, ret, delay) end })