Skip to content

Instantly share code, notes, and snippets.

@Fingercomp
Last active June 12, 2026 02:44
Show Gist options
  • Select an option

  • Save Fingercomp/62137202ec5f607e3f20ca227f6a029c to your computer and use it in GitHub Desktop.

Select an option

Save Fingercomp/62137202ec5f607e3f20ca227f6a029c to your computer and use it in GitHub Desktop.

This gist contains a program that plays the music defined in tracks.

The tracks table stores nested tables — one for each concurrent track. Each such table defines some track properties like its envelope and volume as well as the actual notes. A note can be represented in either of the following forms:

  1. "C#3" means play the note of C♯3, 1 unit long.
  2. 0 indicates a rest (no note is played).
  3. {"C#3", 4} is just like "C#3" but 4 units long instead of the default 1.
  4. {0, 4} is a 4-unit-long rest.

The unit is defined in a constant at the top of each file. The default value of 16 means the unit is a sixteenth note (4 would be the quarter note, you get the idea). You could also tweak other things, like the bpm (how many quarter notes fit in 1 minute).

The volume can be specified with the makeVolume utility function. You can give it a MIDI value (0 to 127) or a dB (FS) — or both, which would combine. It could seem a brilliant idea to make it as load as you can (0 dB), but you'll get an ear-tearing sound as a result, and I wouldn't recommend it. The square, sawtooth, and triangle waveforms are really bright, so -10 dBFS will still be quite load but not in a life-threatening way. Keep in mind the channels are mixed together by adding them up — if you've got 3 tracks, you may want to reduce the volumes greatly.

This program could make for a good foundation for a tracker application, or a MIDI player if you so desire.

Final notes:

  1. If the sound card is not available, it just spits out the methods it'd like to call. Thus you can run the program in vanilla Lua (5.3+), but you won't get any sound this way.

    I used that for debugging (actually running MC is a hassle — hopefully we'll have an easier way to tinker with the sound card soon!).

  2. The 52 stands for the track number — that is, it's the 52nd one I've made. You can listen to the original version if you'd like.

  3. Thanks to a selfless volunteer (@BadCoder) we have a recording of the second version. The audio recording is also available, which is cleaner.

  4. The code is released under the MIT license. The track is released under the CC-BY-SA 4.0 license.

  5. If you have no idea what you've just read — I'm talking about Minecraft and its amazing mods OpenComputers and Computronics.

local MAX_CHANNELS = 8
local BPM = 120
local INSTR_QUEUE_SIZE = 1024
local MAX_DELAY = 250
local UPDATE_INTERVAL_MS = 100
local UNIT = 16
local TAIL = 1000
local TIME_SIGNATURE = 4/4
local function asHexString(s)
return s:gsub(".", function(c) return ("%02x "):format(c:byte()) end)
:sub(1, -2)
end
local function printf(format, ...)
print(format:format(...))
end
local function errf(format, ...)
io.stderr:write(format:format(...) .. "\n")
end
local function makeAdsr(attack, decay, sustain, release)
return setmetatable({
attack = attack * 1000,
decay = decay * 1000,
sustain = sustain,
release = release * 1000,
}, {__eq = function(self, other)
return other
and self.attack == other.attack
and self.decay == other.decay
and self.sustain == other.sustain
and self.release == other.release
end})
end
local function makeVolume(args)
if type(args) == "number" then
local midiVolume = args
return midiVolume / 127
end
local volume = 1
if args.midi then
volume = volume * makeVolume(args.midi)
end
if args.dB then
volume = volume * 10^(args.dB / 20)
end
return volume
end
local tracks = {
{
waveType = "triangle",
adsr = makeAdsr(0.012, 4.877, 0.770, 0.346),
volume = makeVolume {
dB = -8.5,
},
playMode = 5,
{0, 64},
"D4", "E4", "F#4", "A4", {"B4", 2}, {"F#4", 2},
"A4", 0, "F#4", "B4", 0, "A4", 0, "E4",
"G4", "B4", "C#5", "B4", {"D5", 2}, {"B4", 2},
"E5", 0, "D5", "C#5", 0, "A4", 0, "F#4",
"A4", "C#5", "A5", "E5", {"F#5", 2}, {"D5", 2},
"G5", 0, "E5", "C#5", 0, "A4", "F#4", "E4",
"G4", "A#4", "B4", "D5", "E5", "C#5", 0, "A5",
"C#5", "E5", "A4", 0, "F#4", 0, "D4", 0,
},
{
waveType = "square",
adsr = makeAdsr(0.012, 4.366, 0.870, 0.221),
volume = makeVolume {
dB = -18.2,
},
playMode = math.huge,
"E3", "D3", "E3", "F#3", {"G3", 2}, {"D3", 2},
"E3", 0, "E3", "D3", {0, 2}, "D3", 0,
"E3", "D3", "E3", "B3", {"G3", 2}, {"F#3", 2},
"A3", 0, "F#3", "A3", 0, "B3", {0, 2},
"E3", "D3", "E3", "F#3", {"A3", 2}, "G3", 0,
"B3", 0, "A3", "E3", {0, 2}, "D3", 0,
"B3", "A3", "G3", "A3", "D4", 0, "D4", "C#4",
0, "A3", 0, "F#3", 0, "A3", {0, 2},
"E3", "D3", "E3", "F#3", {"G3", 2}, {"D3", 2},
"E3", 0, "E3", "D3", {0, 2}, "D3", 0,
"E3", "D3", "E3", "B3", {"G3", 2}, {"F#3", 2},
"A3", 0, "F#3", "A3", 0, "B3", {0, 2},
"E3", "D3", "E3", "F#3", {"A3", 2}, "G3", 0,
"B3", 0, "A3", "E3", {0, 2}, "D3", 0,
"E3", "G3", "B3", "D4", "A3", "C#4", 0, "A3",
0, "E3", "F#3", 0, "A3", 0, "D3", 0,
},
{
waveType = "triangle",
adsr = makeAdsr(0.012, 3.653, 0.690, 0.333),
volume = makeVolume {
dB = -6.9,
},
playMode = math.huge,
{0, 48},
{"G1", 4}, {"A1", 4}, {"C#2", 4}, {"D2", 4},
{"E2", 4}, {"G1", 4}, {"C#2", 4}, {"B1", 4},
{"A1", 4}, {"C#2", 4}, {"F#2", 4}, {"E2", 4},
{"C#2", 4}, {"E2", 4}, {"A1", 4}, {"B1", 4},
{"G1", 4}, {"A1", 4}, {"C#2", 4}, {"D2", 4},
},
}
local function toRealTimeScale(time)
return time * 1000 * 60 / BPM / (UNIT / 4)
end
local function unpackEvent(event)
if type(event) == "table" then
return event[1], toRealTimeScale(event[2])
else
return event, toRealTimeScale(1)
end
end
local function isRest(event)
return unpackEvent(event) == 0
end
local function getDuration(event)
return select(2, unpackEvent(event))
end
local notes = {
A = 0,
B = 2,
C = -9,
D = -7,
E = -5,
F = -4,
G = -2,
}
local function noteToSemitones(note)
local name, accidential, octave = note:match("([A-G])([#b]?)(%d+)")
local semitones = assert(notes[name], "invalid note")
if accidential == "#" then
semitones = semitones + 1
elseif accidential == "b" then
semitones = semitones - 1
end
octave = assert(tonumber(octave), "invalid octave")
return octave * 12 + semitones
end
local A_SEMITONES = noteToSemitones("A4")
local function noteToFreq(note)
return 440 * 2^((noteToSemitones(note) - A_SEMITONES) / 12)
end
local function semitoneDelta(freq1, freq2)
return 12 * math.log(freq2 / freq1, 2)
end
local function compileTrack(track, startTime)
local compiled = {}
local time = -startTime
for i, event in ipairs(track) do
if not isRest(event) then
local note, duration = unpackEvent(event)
local freq = noteToFreq(note)
if time + duration >= 0 then
table.insert(compiled, {math.max(time, 0), "note-on", freq})
end
time = time + duration
if time >= 0 then
table.insert(compiled, {time, "note-off"})
end
else
time = time + getDuration(event)
end
end
-- allow to access track properties like the adsr envelope
return setmetatable(compiled, {__index = function(self, k)
if type(k) == "string" then
return track[k]
end
end})
end
-- we rely on the fact that consecutive simultaneous events on a track are
-- merged in as a group
local function merge(tracks)
local cursors = {}
for i = 1, #tracks, 1 do
cursors[i] = 1
end
local result = {}
while true do
local nextTrackIndex, nextInstr
-- find the next element in order
for i, track in ipairs(tracks) do
local cursor = cursors[i]
if cursor <= #track then
local instr = track[cursor]
if instr and (not nextInstr or nextInstr[1] > instr[1]) then
nextTrackIndex = i
nextInstr = instr
end
end
end
if not nextInstr then
break
end
cursors[nextTrackIndex] = cursors[nextTrackIndex] + 1
table.insert(result, {
track = tracks[nextTrackIndex],
trackIndex = nextTrackIndex,
instr = nextInstr,
})
end
return result
end
local function makeState(kind, time)
return {
kind = kind,
time = time,
}
end
local makeBuffer do
local bufferMeta = {
__index = {
pushInstr = function(self, ...)
table.insert(self, {...})
self.queueSize = self.queueSize + 1
if self.queueSize >= INSTR_QUEUE_SIZE then
self:process()
end
end,
setFreq = function(self, channel, freq)
self:pushInstr("set-freq", channel, freq)
end,
setAdsr = function(self, channel, adsr)
self:pushInstr("set-adsr", channel,
adsr.attack, adsr.decay, adsr.sustain, adsr.release)
end,
resetAdsr = function(self, channel)
self:pushInstr("reset-adsr", channel)
end,
setWaveType = function(self, channel, waveType)
self:pushInstr("wave", channel, waveType)
end,
setVolume = function(self, channel, volume)
self:pushInstr("volume", channel, volume)
end,
open = function(self, channel)
self:pushInstr("open", channel)
end,
close = function(self, channel)
self:pushInstr("close", channel)
end,
delay = function(self, time)
while self.pendingDelay + time >= MAX_DELAY do
local remaining = MAX_DELAY - self.pendingDelay
self:pushInstr("delay", remaining)
self:process()
time = time - remaining
end
if time > 0 then
self:pushInstr("delay", time)
end
self.pendingDelay = self.pendingDelay + time
end,
process = function(self)
table.insert(self, {"process", self.pendingDelay})
self.queueSize = 0
self.pendingDelay = 0
end,
},
}
function makeBuffer()
return setmetatable({
pendingDelay = 0,
queueSize = 0,
}, bufferMeta)
end
end
local makeChannel do
local channelMeta = {
__index = {
pushInstr = function(self, method, ...)
self.instrs[method](self.instrs, self.idx, ...)
end,
setFreq = function(self, freq)
if self.freq ~= freq then
self:pushInstr("setFreq", freq)
self.freq = freq
end
end,
setAdsr = function(self, adsr)
if self.adsr ~= adsr then
if adsr then
self:pushInstr("setAdsr", adsr)
else
self:pushInstr("resetAdsr")
end
self.adsr = adsr
end
end,
setWaveType = function(self, waveType)
if self.waveType ~= waveType then
self:pushInstr("setWaveType", waveType)
self.waveType = waveType
end
end,
setVolume = function(self, volume)
if self.volume ~= volume then
self:pushInstr("setVolume", volume)
self.volume = volume
end
end,
isOpen = function(self)
return self.state.kind == "open"
end,
isClosed = function(self)
return self.state.kind == "closed"
end,
isBusy = function(self, time)
return self:isOpen() or (
self:isClosed()
and self.adsr
and self.state.time + self.adsr.release > time
)
end,
open = function(self, time)
self:pushInstr("open")
self.state = makeState("open", time)
end,
close = function(self, time)
if self:isOpen() then
self:pushInstr("close")
end
self.state = makeState("closed", time)
end,
},
}
function makeChannel(channelIndex, instructionBuffer)
return setmetatable({
idx = channelIndex,
instrs = instructionBuffer,
freq = nil,
adsr = nil,
waveType = nil,
volume = nil,
state = makeState("closed", -math.huge),
}, channelMeta)
end
end
local function isLhsBetter(lhs, rhs, time, freq, track)
if not lhs:isBusy(time) and rhs:isBusy(time) then
return true
end
if lhs:isBusy(time) and rhs:isBusy(time) then
-- changing the settings affects the perceived sound here
-- prefer tracks that have less audible discrepancy
if lhs:isClosed() and not rhs:isClosed() then
return true
elseif not lhs:isClosed() and rhs:isClosed() then
return false
end
if lhs.freq == freq and rhs.freq ~= freq then
return true
elseif lhs.freq ~= freq and rhs.freq == freq then
return false
end
if lhs.waveType == track.waveType and rhs.waveType ~= track.waveType then
return true
elseif lhs.waveType ~= track.waveType and rhs.waveType == track.waveType then
return false
end
if lhs.adsr == track.adsr and rhs.adsr ~= track.adsr then
return true
elseif lhs.adsr ~= track.adsr and rhs.adsr == track.adsr then
return false
end
if lhs.volume == track.volume and rhs.volume ~= track.volume then
return true
elseif lhs.volume ~= track.volume and rhs.volume == track.volume then
return false
end
return false
end
if not lhs:isBusy(time) and not rhs:isBusy(time) then
-- here we just want to minimize the number of instructions,
-- since neither of the channels is playing anything
local lhsPoints, rhsPoints = 0, 0
if lhs.freq == freq then lhsPoints = lhsPoints + 1 end
if rhs.freq == freq then rhsPoints = rhsPoints + 1 end
if lhs.waveType == track.waveType then lhsPoints = lhsPoints + 1 end
if rhs.waveType == track.waveType then rhsPoints = rhsPoints + 1 end
if lhs.adsr == track.adsr then lhsPoints = lhsPoints + 1 end
if rhs.adsr == track.adsr then rhsPoints = rhsPoints + 1 end
if lhs.volume == track.volume then lhsPoints = lhsPoints + 1 end
if rhs.volume == track.volume then rhsPoints = rhsPoints + 1 end
return lhsPoints < rhsPoints
end
-- incomparable
return false
end
local function findChannel(channels, time, freq, track)
local ranked = {}
for _, channel in ipairs(channels) do
table.insert(ranked, channel)
end
table.sort(ranked, function(lhs, rhs)
return isLhsBetter(lhs, rhs, time, freq, track)
end)
return ranked[1]
end
local function isGlide(channel, freq, glideDepth)
return channel:isOpen()
and math.abs(channel.freq - freq) >= 0.05
and math.abs(semitoneDelta(channel.freq, freq)) <= glideDepth
end
local allocateChannels do
-- assumes at most one channel is open for any given track at a time
local channelTrackerMeta = {
__index = {
add = function(self, track, channel)
self._tracks[track] = self._tracks[track] or {}
self._tracks[track][channel] = true
self._tracks[track].last = channel
self._channels[channel] = track
end,
removeChannel = function(self, channel)
local track = self._channels[channel]
self._channels[channel] = nil
if not track then
return nil
end
self._tracks[track][channel] = nil
if self._tracks[track].last == channel then
self._tracks[track].last = nil
end
return track
end,
removeFreeChannels = function(self, time)
for channel in pairs(self._channels) do
if not channel:isBusy(time) then
self:removeChannel(channel)
end
end
end,
getLastChannel = function(self, track)
if not self._tracks[track] then
return nil
end
return self._tracks[track].last
end,
},
}
local function makeChannelTracker()
return setmetatable({
_tracks = {},
_channels = {},
}, channelTrackerMeta)
end
function allocateChannels(compiledTracks, channelCount, forceMode)
local sortedInstructions = merge(compiledTracks)
local time = 0
local channels = {}
local buffer = makeBuffer()
for i = 1, channelCount, 1 do
table.insert(channels, makeChannel(i, buffer))
end
local time = 0
local tracker = makeChannelTracker()
for i, trackInstr in ipairs(sortedInstructions) do
local track = trackInstr.track
local instr = trackInstr.instr
local instrTime, instrKind = table.unpack(instr)
if time < instrTime then
buffer:delay(instrTime - time)
end
time = math.max(time, instrTime)
tracker:removeFreeChannels(time)
local playMode = forceMode or track.playMode
if instrKind == "note-on" then
local freq = instr[3]
local channel
local prevChannel = tracker:getLastChannel(track)
local retrigger = true
if prevChannel then
if playMode == "mono" then
channel = prevChannel
elseif type(playMode) == "number"
and isGlide(prevChannel, freq, playMode) then
channel = prevChannel
retrigger = false
end
end
if not channel then
channel = findChannel(channels, time, freq, track)
end
channel:setFreq(freq)
channel:setWaveType(track.waveType)
channel:setAdsr(track.adsr)
channel:setVolume(track.volume * (instr.velocity or 1))
if retrigger then
channel:open(time)
end
local prevTrack = tracker:removeChannel(channel)
if prevTrack and prevTrack ~= track then
-- we're killing a note that was managed by a different track
errf("!!! [%.3f] killed channel %d", time / 1000, channel.idx)
end
tracker:add(track, channel)
elseif instrKind == "note-off" then
local channel = tracker:getLastChannel(track)
if channel then
local shouldClose = true
if type(playMode) == "number" then
local nextTrackInstr = sortedInstructions[i + 1]
if not nextTrackInstr then
goto doClose
end
if nextTrackInstr.track ~= track then
goto doClose
end
local nextInstrTime, nextInstrKind, nextInstrFreq =
table.unpack(nextTrackInstr.instr)
if nextInstrTime > time or nextInstrKind ~= "note-on" then
goto doClose
end
shouldClose = not isGlide(channel, nextInstrFreq, playMode)
end
::doClose::
if shouldClose then
channel:close(time)
end
end
else
error("unknown event type: " .. instrKind)
end
end
buffer:delay(TAIL)
buffer:process()
-- the second `process` forces the program to wait until the playback ends
buffer:process()
return buffer
end
end
local loadTrack do
local waveTypes = {
[0x00] = "sine",
[0x01] = "sawtooth",
[0x02] = "square",
[0x03] = "triangle",
[0x04] = "noise",
}
local playModes = {
[0x00] = "mono",
[0xff] = "poly",
}
local eventKinds = {
[0x00] = "note-press",
}
local eventParsers = {
["note-press"] = function(self, trackId, eventId, event)
event.freq = 440 * 2^((-9 + self:readI8()) / 12)
event.velocity = self:readU8() / 0xff
event.duration = self:readU32()
end,
}
local decoderMeta = {
__index = {
error = function(self, format, ...)
error(("Could not load %s: " .. format):format(self._path, ...), 0)
end,
read = function(self, n)
local bytes, err = self._f:read(n)
if not bytes and err then
self:error("%s", err)
end
if not bytes or #bytes < n then
self:error("unexpected eof: need %d bytes, got %d",
n, bytes and #bytes or 0)
end
self._pos = self._pos + #bytes
return bytes
end,
expect = function(self, bytes)
local startPos = self._pos
local actual = self:read(#bytes)
if bytes ~= actual then
self:error("expected %s at byte %d", asHexString(bytes), startPos)
end
return actual
end,
readU32 = function(self)
return (">I4"):unpack(self:read(4))
end,
readU24 = function(self)
return (">I3"):unpack(self:read(3))
end,
readU16 = function(self)
return (">I2"):unpack(self:read(2))
end,
readU8 = function(self)
return (">I1"):unpack(self:read(1))
end,
readI8 = function(self)
return (">i1"):unpack(self:read(1))
end,
readMagic = function(self)
self:expect("sndc")
end,
readHeaderExtension = function(self)
local nameLength = self:readU16()
local name = self:read(nameLength)
local dataLength = self:readU32()
self:read(dataLength)
errf("ignoring an unrecognized extension %q (size %d)",
name, dataLength)
return nil
end,
readHeader = function(self)
local result = {}
result.tempo = self:readU32()
result.timeSignature = {}
result.timeSignature.numerator = self:readU8()
result.timeSignature.denominator = self:readU8()
result.ticksPerQuarter = self:readU32()
result.extensions = {}
local extensionCount = self:readU8()
for i = 1, extensionCount, 1 do
table.insert(result.extensions, self:readHeaderExtension())
end
return result
end,
readWaveType = function(self, trackId)
local waveType = self:readU8()
if not waveTypes[waveType] then
self:error("track #%d has specified an unknown wave type %02x",
trackId, waveType)
end
return waveTypes[waveType]
end,
readTrackExtension = function(self, trackId)
local nameLength = self:readU16()
local name = self:read(nameLength)
local dataLength = self:readU16()
self:read(dataLength)
errf("Ignoring an unrecognized extension %q (size %d) in track #%d",
name, dataLength, trackId)
return nil
end,
readTrackEvent = function(self, trackId, eventId)
local eventKind = self:readU8()
if not eventKinds[eventKind] then
self:error("event kind %02x is unknown (event #%d, track #%d)",
eventKind, eventId, trackId)
end
eventKind = eventKinds[eventKind]
if not eventParsers[eventKind] then
self:error("unsupported event %q (event #%d, track #%d)",
eventKind, eventId, trackId)
end
local event = {
kind = eventKind,
time = self:readU32(),
}
eventParsers[eventKind](self, trackId, eventId, event)
return event
end,
readTrack = function(self, trackId)
local track = {}
track.waveType = self:readWaveType(trackId)
local adsrPresence = self:readU8()
if adsrPresence == 0x01 then
track.adsr = {}
track.adsr.attack = self:readU24()
track.adsr.decay = self:readU24()
track.adsr.sustain = self:readU16() / 0xffff
track.adsr.release = self:readU24()
elseif adsrPresence ~= 0x00 then
self:error("track #%d has specified an unknown value (%02x) for adsr presence",
trackId, adsrPresence)
end
track.volume = self:readU16() / 0xffff
local playMode = self:readU8()
track.playMode = playModes[playMode] or playMode
local extensionCount = self:readU8()
track.extensions = {}
for i = 1, extensionCount, 1 do
table.insert(track.extensions, self:readTrackExtension(trackId))
end
track.events = {}
local eventCount = self:readU32()
for i = 1, eventCount, 1 do
table.insert(track.events, self:readTrackEvent(trackId, i))
end
return track
end,
readTracks = function(self)
local trackCount = self:readU8()
local tracks = {}
for i = 1, trackCount, 1 do
table.insert(tracks, self:readTrack(i))
end
return tracks
end,
decode = function(self)
self:readMagic()
local header = self:readHeader()
local tracks = self:readTracks()
return {
header = header,
tracks = tracks,
}
end,
},
}
local function sndcTimeToRealTimeScale(sndc, time)
return sndc.header.tempo * (time / sndc.header.ticksPerQuarter)
end
local function processSndcTrack(track, sndc)
local compiled = {}
for i, event in ipairs(track.events) do
if event.kind == "note-press" then
table.insert(compiled, {
sndcTimeToRealTimeScale(sndc, event.time),
"note-on",
event.freq,
velocity = event.velocity,
})
local offTime = event.time + event.duration
if track.events[i + 1] then
local nextEvent = track.events[i + 1]
offTime = math.min(offTime, nextEvent.time)
end
table.insert(compiled, {
sndcTimeToRealTimeScale(sndc, offTime),
"note-off",
})
end
end
return setmetatable(compiled, {__index = {
waveType = track.waveType,
adsr = track.adsr,
volume = track.volume,
playMode = track.playMode,
}})
end
local function fromSndc(sndc)
local compiledTracks = {}
for i, track in ipairs(sndc.tracks) do
table.insert(compiledTracks, processSndcTrack(track, sndc))
end
return compiledTracks
end
function loadTrack(f, path)
local decoder = setmetatable({
_f = f,
_path = path,
_pos = 0,
}, decoderMeta)
local sndc = decoder:decode()
return fromSndc(sndc)
end
end
local function makeSoundCardStub()
local sound
sound = {
modes = {
sine = 1,
square = 2,
triangle = 3,
sawtooth = 4,
noise = 5,
"sine",
"square",
"triangle",
"sawtooth",
"noise",
},
channel_count = 8,
setTotalVolume = function(volume)
printf("setTotalVolume(%f)", volume)
end,
clear = function()
print("clear()")
end,
open = function(channel)
printf("open(%d)", channel)
end,
close = function(channel)
printf("close(%d)", channel)
end,
setWave = function(channel, waveType)
printf("setWave(%d, %s)", channel, sound.modes[waveType])
end,
setFrequency = function(channel, frequency)
printf("setFrequency(%d, %f)", channel, frequency)
end,
setLFSR = function(channel, initial, mask)
printf("setLFSR(%d, %x, %x)", channel, initial, mask)
end,
delay = function(duration)
printf("delay(%f)", duration)
return true
end,
setFM = function(channel, modIndex, intensity)
printf("setFM(%d, %d, %f)", channel, modIndex, intensity)
end,
resetFM = function(channel)
printf("resetFM(%d)", channel)
end,
setAM = function(channel, modIndex)
printf("setAM(%d, %d)", channel)
end,
resetAM = function(channel)
printf("resetAM(%d)", channel)
end,
setADSR = function(channel, attack, decay, attenuation, release)
printf("setADSR(%d, %f, %f, %f, %f)",
channel, attack, decay, attenuation, release)
end,
resetEnvelope = function(channel)
printf("resetEnvelope(%d)", channel)
end,
setVolume = function(channel, volume)
printf("setVolume(%d, %f)", channel, volume)
end,
process = function()
print("process()")
return true
end,
}
return sound
end
local pullEvent
if not pcall(function() pullEvent = require("event").pull end) then
pullEvent = function(time)
errf("event.pull(%f)", time)
end
end
local function sleep(time)
local ticks = math.floor(time * 20)
if pullEvent(ticks / 20) == "interrupted" then
return false
end
local spinTime = time - ticks / 20
if os.sleep then
local start = os.clock()
while os.clock() - start < spinTime do end
else
printf("spin loop: %f", spinTime)
end
return true
end
local function resetSoundCard(sound, totalVolume)
sound.clear()
for i = 1, sound.channel_count, 1 do
sound.resetAM(i)
sound.resetFM(i)
sound.resetEnvelope(i)
sound.close(i)
end
sound.setTotalVolume(totalVolume)
sound.process()
end
local function parseArgs(...)
local args = {...}
local i = 1
while i <= #args do
local drop = true
local arg = args[i]
if arg:sub(1, 2) == "--" then
local key = arg:sub(3)
local value = true
local equalsPos = key:find("=")
if equalsPos then
value = key:sub(equalsPos + 1)
key = key:sub(1, equalsPos - 1)
end
args[key] = value
elseif arg:sub(1, 1) == "-" then
for c in arg:sub(2):gmatch(".") do
args[c] = true
end
else
drop = false
end
if drop then
table.remove(args, i)
else
i = i + 1
end
end
return args
end
local function printHelp(format, ...)
if message then
errf(format, ...)
end
local helpString = [[
Usage: player [options...] [<input-file>]
Arguments:
<input-file>
A .sndc file to play instead of the built-in track.
Options:
-h Show this help message
--skip=<MEASURES-TO-SKIP>
The number of measures to skip in the beginning.
--total-volume=<VOLUME>
Set the sound card volume. Default: 1.0.
--force-mode=<MODE>
Force a play mode for all tracks. Valid values: mono, poly.
-d, --dry-run
Do not actually produce any sound, instead print the instructions that
would be queued.
]]
(format and io.stderr or io.stdout):write(helpString)
end
local function parseOption(optionName, optionValue, ty, default)
if not optionValue then
return default
end
if ty == "boolean" then
if type(optionValue) ~= "boolean" then
printHelp("The option %s does not accept values", optionName)
os.exit(1)
end
return optionValue
end
if type(optionValue) == "boolean" then
printHelp("The option %s requires a value", optionName)
os.exit(1)
end
if ty == "integer" or ty == "float" then
local value = tonumber(optionValue)
if not value then
printHelp("Invalid value for %s: expected %s", optionName, ty)
os.exit(1)
end
if ty == "integer" then
value = math.floor(value)
end
return value
end
if ty == "string" then
return optionValue
end
error("Unknown option type: " .. ty, 1)
end
local function popKey(tbl, key)
local value = tbl[key]
tbl[key] = nil
return value
end
local function any(...)
for i = 1, select("#", ...), 1 do
if select(i, ...) then
return true
end
end
return false
end
local function readOptions(args)
local options = {}
if parseOption("h", args.h, "boolean")
or parseOption("help", args.help, "boolean") then
printHelp()
os.exit(0)
end
if #args > 1 then
printHelp("Too many arguments provided")
os.exit(1)
end
options.input = parseOption("input-file", table.remove(args, 1), "string")
options.skip = parseOption("skip", popKey(args, "skip"), "float", 0)
options.totalVolume =
parseOption("total-volume", popKey(args, "total-volume"), "float", 1)
options.forceMode =
parseOption("force-mode", popKey(args, "force-mode"), "string")
-- XXX: must not use the short-curcuiting `or` because we have to process
-- all the provided arguments
options.dryRun = any(
parseOption("d", popKey(args, "d"), "boolean", false),
parseOption("dry-run", popKey(args, "dry-run"), "boolean", false)
)
if options.forceMode then
if options.forceMode ~= "mono" and options.forceMode ~= "poly" then
printHelp("Invalid value for --force-mode")
os.exit(1)
end
end
local unknownOption = next(args)
if unknownOption then
printHelp("Unknown option " .. unknownOption)
os.exit(1)
end
return options
end
local args = parseArgs(...)
local options = readOptions(args)
local sound
local useStub = options.dryRun
if not useStub and
not pcall(function() sound = require("component").sound end) then
errf("Using a sound card stub because the sound card is not available")
useStub = true
end
if useStub then
sound = makeSoundCardStub()
end
local compiledTracks
if options.input then
local f, err = io.open(options.input, "rb")
if not f then
errf("Could not open %s for reading: %s",
options.input, err or "unknown error")
os.exit(1)
end
compiledTracks = loadTrack(f, options.input)
printf("Loaded %d tracks", #compiledTracks)
print(require("serialization").serialize(compiledTracks[7][1]))
else
local startTime = toRealTimeScale(options.skip * UNIT * TIME_SIGNATURE)
compiledTracks = {}
for i, track in ipairs(tracks) do
compiledTracks[i] = compileTrack(track, startTime)
end
end
local channelCount = math.min(sound.channel_count, MAX_CHANNELS)
local instructions =
allocateChannels(compiledTracks, channelCount, options.forceMode)
resetSoundCard(sound, options.totalVolume)
local getCurrentTime = not useStub
and require("computer").uptime
or function() end
local playbackStart = getCurrentTime()
local interrupted = false
local function doProcess()
local interrupted = false
while true do
local currentTime = getCurrentTime()
if currentTime then
local time = currentTime - playbackStart
io.write(("\rTime: %02d:%05.2f"):format(
math.floor(time / 60),
time % 60
))
end
local success, timeout = sound.process()
if success then break end
if type(timeout) == "number" then
timeout = math.min(timeout, UPDATE_INTERVAL_MS)
if not sleep(timeout / 1000) then
interrupted = true
end
else
assert(success, timeout)
end
end
return interrupted
end
for _, instr in ipairs(instructions) do
if interrupted then
break
end
if instr[1] == "set-freq" then
sound.setFrequency(instr[2], instr[3])
elseif instr[1] == "set-adsr" then
sound.setADSR(instr[2], instr[3], instr[4], instr[5], instr[6])
elseif instr[1] == "reset-adsr" then
sound.resetEnvelope(instr[2])
elseif instr[1] == "wave" then
sound.setWave(instr[2], sound.modes[instr[3]])
elseif instr[1] == "volume" then
sound.setVolume(instr[2], instr[3])
elseif instr[1] == "open" then
sound.open(instr[2])
elseif instr[1] == "close" then
sound.close(instr[2])
elseif instr[1] == "delay" then
assert(sound.delay(instr[2]))
elseif instr[1] == "process" then
interrupted = doProcess()
else
error("unrecognized instruction kind: " .. instr[1])
end
end
resetSoundCard(sound, 1)
doProcess()
doProcess()
print("")
local function asHexString(s)
return s:gsub(".", function(c)
return ("%02x "):format(c:byte())
end):sub(1, -2)
end
local function printf(format, ...)
print(format:format(...))
end
local function errf(format, ...)
io.stderr:write(format:format(...) .. "\n")
end
local function makeMidiParser(input)
local pos = 0
local limits = {}
local function pushLimit(n)
table.insert(limits, {
n = n,
pos = pos,
})
end
local function popLimit()
table.remove(limits)
if #limits == 0 then
error("#limits is zero")
end
end
local function getLimit()
return limits[#limits]
end
pushLimit(math.huge)
local function parserError(message)
error(("error at 0x%x: %s"):format(pos, message), 0)
end
local function readN(n, allowEof)
if n == 0 then
return ""
end
local result = input:read(n)
local limit = getLimit()
if result then
pos = pos + #result
limit.n = limit.n - #result
end
if limit.n < 0 then
parserError(("the length specified before 0x%x is invalid"):format(
limit.pos))
end
if not result then
if allowEof then
return
end
parserError(("unexpected eof: need %d bytes, got 0"):format(n))
end
if #result ~= n then
parserError(("unexpected eof: need %d bytes, got %d"):format(n, #result))
end
return result
end
local function skipToLimit()
assert(getLimit().n ~= math.huge, "the limit is infinite")
readN(getLimit().n)
popLimit()
end
local function expect(bytes, allowEof)
local actual = readN(#bytes, allowEof)
if not actual then
return
end
if actual ~= bytes then
parserError(("expected %s, got %s"):format(
asHexString(bytes), asHexString(actual)))
end
return actual
end
local function readU32()
return (">I4"):unpack(readN(4))
end
local function readU24()
return (">I3"):unpack(readN(3))
end
local function readU16()
return (">I2"):unpack(readN(2))
end
local function readByte()
return (">I1"):unpack(readN(1))
end
local function readI8()
return (">i1"):unpack(readN(1))
end
local function readVarint()
local value = 0
repeat
local byte = readByte()
value = (value << 7) | (byte & 0x7f)
until byte < 0x80
return value
end
local function parseMidiHeader()
expect("MThd")
pushLimit(readU32())
local format = readU16()
if format ~= 1 then
parserError(("unsupported MIDI format %d"):format(format))
end
local trackCount = readU16()
local division = readU16()
coroutine.yield({
kind = "header",
format = format,
trackCount = trackCount,
division = division,
})
popLimit()
end
local function isChannelMessage(status)
return status & 0xf0 ~= 0xf0
end
local function isSysexMessage(status)
return status & 0xf0 == 0xf0 and status ~= 0xff
end
local function readDataByte(byte)
byte = byte or readByte()
if byte & 0x80 ~= 0 then
parserError(("expected a data byte, got 0x%02x"):format(dataByte))
end
return byte
end
local function parseChannelMessage(deltaTime, status, dataByte)
local channel = status & 0xf
local messageKind = (status >> 4) & 0x7
dataByte = readByte(dataByte)
if messageKind == 0 then
coroutine.yield({
kind = "note-off",
deltaTime = deltaTime,
key = dataByte,
velocity = readDataByte(),
})
elseif messageKind == 1 then
coroutine.yield({
kind = "note-on",
deltaTime = deltaTime,
key = dataByte,
velocity = readDataByte(),
})
elseif messageKind == 2 then
coroutine.yield({
kind = "poly-aftertouch",
deltaTime = deltaTime,
key = dataByte,
pressure = readDataByte(),
})
elseif messageKind == 3 then
coroutine.yield({
kind = "control-change",
deltaTime = deltaTime,
control = dataByte,
value = readDataByte(),
})
elseif messageKind == 4 then
coroutine.yield({
kind = "program-change",
deltaTime = deltaTime,
program = dataByte,
})
elseif messageKind == 5 then
coroutine.yield({
kind = "channel-aftertouch",
deltaTime = deltaTime,
pressure = dataByte,
})
elseif messageKind == 6 then
coroutine.yield({
kind = "pitch-wheel",
deltaTime = deltaTime,
value = ((readDataByte() << 7) | dataByte) - 0x2000
})
else
error("unknown channel message kind " .. messageKind)
end
end
local function parseSysexMessage(deltaTime, status)
local sysexKind = status & 0xf
if sysexKind == 0 or sysexKind == 7 then
local length = readVarint()
local data = readN(length)
coroutine.yield({
kind = sysexKind == 0 and "sysex" or "sysex-cont",
deltaTime = deltaTime,
data = data,
})
elseif sysexKind == 2 then
local lsb = readDataByte()
local msb = readDataByte()
coroutine.yield({
kind = "song-position-pointer",
deltaTime = deltaTime,
position = (msb << 7) | lsb,
})
elseif sysexKind == 3 then
coroutine.yield({
kind = "song-select",
deltaTime = deltaTime,
song = readDataByte(),
})
elseif sysexKind == 6 then
coroutine.yield({
kind = "tune-request",
deltaTime = deltaTime,
})
elseif sysexKind == 8 then
coroutine.yield({
kind = "timer-clock",
deltaTime = deltaTime,
})
elseif sysexKind == 10 then
coroutine.yield({
kind = "start",
deltaTime = deltaTime,
})
elseif sysexKind == 11 then
coroutine.yield({
kind = "continue",
deltaTime = deltaTime,
})
elseif sysexKind == 12 then
coroutine.yield({
kind = "stop",
deltaTime = deltaTime,
})
elseif sysexKind == 14 then
coroutine.yield({
kind = "active-sensing",
deltaTime = deltaTime,
})
else
coroutine.yield({
kind = "sysex-undefined",
sysexKind = sysexKind,
deltaTime = deltaTime,
})
end
end
local function parseMetaEvent(deltaTime)
local eventType = readDataByte()
local length = readVarint()
pushLimit(length)
local endOfTrack = false
if eventType == 0 then
coroutine.yield({
kind = "sequence-number",
deltaTime = deltaTime,
sequence = readU16(),
})
elseif eventType == 1 then
coroutine.yield({
kind = "text-event",
deltaTime = deltaTime,
text = readN(length),
})
elseif eventType == 2 then
coroutine.yield({
kind = "copyright-notice",
deltaTime = deltaTime,
notice = readN(length),
})
elseif eventType == 3 then
coroutine.yield({
kind = "track-name",
deltaTime = deltaTime,
name = readN(length),
})
elseif eventType == 4 then
coroutine.yield({
kind = "instrument-name",
deltaTime = deltaTime,
name = readN(length),
})
elseif eventType == 5 then
coroutine.yield({
kind = "lyric",
deltaTime = deltaTime,
lyric = readN(length),
})
elseif eventType == 6 then
coroutine.yield({
kind = "marker",
deltaTime = deltaTime,
text = readN(length),
})
elseif eventType == 7 then
coroutine.yield({
kind = "cue-point",
deltaTime = deltaTime,
text = readN(length),
})
elseif eventType == 0x20 then
coroutine.yield({
kind = "channel-prefix",
deltaTime = deltaTime,
channel = readByte(),
})
elseif eventType == 0x2f then
coroutine.yield({
kind = "end-of-track",
deltaTime = deltaTime,
})
endOfTrack = true
elseif eventType == 0x51 then
coroutine.yield({
kind = "set-tempo",
deltaTime = deltaTime,
tempo = readU24(),
})
elseif eventType == 0x54 then
local hours = readByte()
local minutes = readByte()
local seconds = readByte()
local frames = readByte()
local frameFrac = readByte()
coroutine.yield({
kind = "smpte-offset",
deltaTime = deltaTime,
hours = hours,
minutes = minutes,
seconds = seconds,
frames = frames,
frameFrac = frameFrac,
})
elseif eventType == 0x58 then
local num = readByte()
local den = readByte()
local metronomeClocks = readByte()
local quarter32 = readByte()
coroutine.yield({
kind = "time-signature",
deltaTime = deltaTime,
numerator = num,
denominator = den,
metronomeClocks = metronomeClocks,
quarter32 = quarter32,
})
elseif eventType == 0x59 then
local accidentials = readI8()
local key = readU8()
if key == 0 then
key = "major"
elseif key == 1 then
key = "minor"
end
coroutine.yield({
kind = "key-signature",
deltaTime = deltaTime,
accidentials = accidentials,
key = key,
})
elseif eventType == 0x7f then
coroutine.yield({
kind = "meta-sequencer-specific",
deltaTime = deltaTime,
data = readN(length),
})
else
coroutine.yield({
kind = "meta-unrecognized",
deltaTime = deltaTime,
meta = eventType,
data = readN(length),
})
end
skipToLimit()
return endOfTrack
end
local function parseMidiTrack()
if not expect("MTrk", true) then
return
end
pushLimit(readU32())
local runningStatus
local finished = false
coroutine.yield({kind = "track"})
while not finished do
local deltaTime = readVarint()
local status = readByte()
local dataByte
if status & 0x80 == 0 then
if not runningStatus then
parserError(("expected a track event status byte, got 0x%02x"):format(
status))
end
dataByte = status
status = runningStatus
elseif isChannelMessage(status) then
-- channel message
runningStatus = status
else
-- sysex / meta
runningStatus = nil
end
if isChannelMessage(status) then
parseChannelMessage(deltaTime, status, dataByte)
elseif isSysexMessage(status) then
parseSysexMessage(deltaTime, status)
else
finished = parseMetaEvent(deltaTime)
end
end
popLimit()
return true
end
local co = coroutine.create(function()
parseMidiHeader()
while parseMidiTrack() do end
coroutine.yield({kind = "end-of-file"})
end)
-- this is exactly what `coroutine.wrap` does except it works correctly in
-- OpenOS as well...
return function(...)
local executionResult = table.pack(coroutine.resume(co, ...))
if executionResult[1] then
return table.unpack(executionResult, 2, executionResult.n)
else
error(executionResult[2], 2)
end
end
end
local compileTracks do
local trackMeta = {
__index = {
getAvailableVoice = function(self)
for i = 1, self._voices.n + 1, 1 do
if not self._voices[i] then
return i
end
end
end,
allocateVoice = function(self, voice, key)
assert(not self._voices[voice])
self._voices[voice] = key
self._voices.n = math.max(self._voices.n, voice)
end,
deallocateVoice = function(self, voice)
assert(self._voices[voice])
self._voices[voice] = nil
if self._voices.n == voice then
for i = voice - 1, 0, -1 do
if self._voices[i] or i == 0 then
self._voices.n = i
break
end
end
end
end,
keyOn = function(self, time, key, velocity)
if self._keys[key] then
self:keyOff(key, time)
end
local event = {
onAt = time,
key = key,
velocity = velocity,
voice = self:getAvailableVoice(),
}
table.insert(self, event)
self._keys[key] = event
self:allocateVoice(event.voice, key)
end,
keyOff = function(self, time, key)
local event = self._keys[key]
if not event then
return
end
event.offAt = time
self:deallocateVoice(event.voice)
self._keys[key] = nil
end,
close = function(self, time)
for key, _ in pairs(self._keys) do
self:keyOff(time, key)
end
end,
},
}
local function makeTrack()
return setmetatable({
_keys = {},
_voices = {n = 0},
info = {
name = nil,
},
}, trackMeta)
end
local trackListMeta = {
__index = {
getTrack = function(self, trackId)
if not self[trackId] then
self[trackId] = makeTrack()
end
return self[trackId]
end,
isTrackEmpty = function(self, trackId)
if not self[trackId] then
return true
end
if #self[trackId] == 0 then
return true
end
return false
end,
keyOn = function(self, trackId, time, key, velocity)
self:getTrack(trackId):keyOn(time, key, velocity)
end,
keyOff = function(self, trackId, time, key)
self:getTrack(trackId):keyOff(time, key)
end,
},
}
local function makeTrackList()
return setmetatable({}, trackListMeta)
end
local firstTrackEventHandlers = {
["track"] = function(self, event)
self:pushTrack()
end,
["track-name"] = function(self, event)
self.info.name = event.name
end,
["time-signature"] = function(self, event)
self.info.timeSignature.numerator = event.numerator
self.info.timeSignature.denominator = 1 << event.denominator
end,
["set-tempo"] = function(self, event)
self.info.tempo = event.tempo / 1000000
end,
["end-of-track"] = function(self, event)
self:popTrack()
end,
}
local mainTrackEventHandlers = {
["end-of-track"] = function(self, event)
self:popTrack()
end,
["track-name"] = function(self, event)
self:getCurrentTrack().info.name = event.name
end,
["note-on"] = function(self, event)
self._tracks:keyOn(
self._currentTrackId,
self._time,
event.key,
event.velocity / 127
)
end,
["note-off"] = function(self, event)
self._tracks:keyOff(
self._currentTrackId,
self._time,
event.key
)
end,
}
local compilerMeta = {
__index = {
pushTrack = function(self)
if self._currentTrackId then
error(("expected an eof-of-track event on track %d"):format(
self._currentTrackId))
end
self._currentTrackId = self._nextTrackId
self._nextTrackId = self._nextTrackId + 1
self._time = 0
end,
popTrack = function(self)
if not self._currentTrackId then
error("encountered an unexpected end-of-track event")
end
self:getCurrentTrack():close(self._time)
self._currentTrackId = nil
end,
getCurrentTrack = function(self)
assert(self._currentTrackId)
return self._tracks:getTrack(self._currentTrackId)
end,
read = function(self)
local event = self._midi()
if not event then
error("the MIDI stream has ended abruptly")
end
if event.deltaTime then
if not self._time then
error("got a MIDI event with a time delta specified where not allowed")
end
self._time = self._time + event.deltaTime
end
return event
end,
expect = function(self, kind, event)
local event = event or self:read()
if event.kind ~= kind then
error(("expected a %s MIDI event, got %s"):format(kind, event.kind))
end
return event
end,
readFirstTrack = function(self)
firstTrackEventHandlers["track"](self, self:expect("track"))
while self._currentTrackId do
local event = self:read()
if firstTrackEventHandlers[event.kind] then
if self._time > 0 then
error("non-zero time deltas are not supported on the first track")
end
firstTrackEventHandlers[event.kind](self, event)
else
errf("Ignoring %s (first track)", event.kind)
end
end
if not self.info.tempo then
errf("the tempo is not set: assuming 120 bpm")
self.info.tempo = 60 / 120
end
end,
readMainTrack = function(self)
self:pushTrack()
while self._currentTrackId do
local event = self:read()
if mainTrackEventHandlers[event.kind] then
mainTrackEventHandlers[event.kind](self, event)
else
errf("Ignoring %s (track %d)", event.kind, self._currentTrackId)
end
end
end,
readMainTracks = function(self)
while true do
local event = self:read()
if event.kind == "end-of-file" then
break
end
self:expect("track", event)
self:readMainTrack()
end
end,
compile = function(self)
local header = self:expect("header")
self.info.division = header.division
self:readFirstTrack()
self:readMainTracks()
local tracks = {}
local trackCount = self._nextTrackId - 2
for i = 2, self._nextTrackId - 1, 1 do
if not self._tracks:isTrackEmpty(i) then
local voiceMap = {}
local track = self._tracks:getTrack(i)
for _, event in ipairs(track) do
if not voiceMap[event.voice] then
table.insert(tracks, {
info = track.info,
trackId = i - 1,
})
voiceMap[event.voice] = #tracks
end
table.insert(tracks[voiceMap[event.voice]], event)
end
end
end
tracks.info = self.info
tracks.trackCount = trackCount
return tracks
end,
},
}
function compileTracks(midiReader)
local compiler = setmetatable({
_midi = midiReader,
_tracks = makeTrackList(),
_nextTrackId = 1,
_currentTrackId = nil,
_time = 0,
info = {
name = nil,
timeSignature = {
numerator = 4,
denominator = 4,
},
division = nil,
tempo = nil,
},
}, compilerMeta)
return compiler:compile()
end
end
local function checkInstrumentProperty(instrIdx, key, value)
return function(checks)
for _, check in ipairs(checks) do
local msg, replacement = check(value)
if msg == true then
if replacement ~= nil then
return replacement
end
break
end
if msg then
if key then
key = "." .. key
else
key = ""
end
errf("Error in instrument #%d%s: %s", instrIdx, key, msg)
os.exit(2)
end
end
return value
end
end
local checks = {
type = function(...)
local types = {...}
assert(#types >= 1, "expected a type")
return function(value)
for _, ty in ipairs(types) do
if type(value) == ty then
return
end
end
if #types == 1 then
return "expected " .. types[1]
elseif #types == 2 then
return ("expected %s or %s"):format(table.unpack(types))
end
local msg = "expected " .. types[1]
for i = 2, #types, 1 do
msg = msg .. ", or " .. types[i]
end
return msg
end
end,
finite = function()
return function(value)
if value ~= value then
return "nan is not allowed"
end
if value == math.huge or value == -math.huge then
return "the value must be finite"
end
end
end,
bounded01 = function()
return function(value)
if value >= 0 and value <= 1 then
return
end
return "expected a value between 0 and 1"
end
end,
nonNegative = function()
return function(value)
if value < 0 then
return "expected a non-negative value"
end
end
end,
allowNil = function()
return function(value)
if value == nil then
return true
end
end
end,
default = function(default)
return function(value)
if value == nil then
return true, default
end
end
end,
required = function()
return function(value)
if value == nil then
return "required a value"
end
end
end,
keyOf = function(tbl)
return function(value)
if not tbl[value] then
return ("%s is not a valid value"):format(value)
end
end
end,
message = function(msgOverride, check)
return function(value)
local msg, replacement = check(value)
if msg == true then
return msg, replacement
elseif not msg then
return
else
return msgOverride:format(value)
end
end
end,
cond = function(cond, thenCheck, elseCheck)
return function(value)
if cond(value) then
return thenCheck(value)
else
return elseCheck(value)
end
end
end,
integer = function()
return function(value)
value = math.tointeger(value)
if value then
return true, value
end
return "expected an integer"
end
end,
bounded = function(min, max)
return function(value)
if min <= value and value <= max then
return
end
return ("expected a value between %s and %s, got %s"):format(
min, max, value)
end
end,
all = function(checks)
return function(value)
for _, check in ipairs(checks) do
local msg, replacement = check(value)
if msg then
if msg == true and replacement ~= nil then
return true, replacement
end
return msg, replacement
end
end
end
end,
}
local waveTypes = {
sine = 0x00,
sawtooth = 0x01,
square = 0x02,
triangle = 0x03,
noise = 0x04,
}
local playModes = {
mono = 0x00,
poly = 0xff,
}
local eventKinds = {
["note-press"] = 0x00,
}
local function loadInstrumentDefinitions(f, path)
local code = assert(f:read("*a"))
f:close()
local chunk, err = load(code, "@" .. path, "t", {
makeAdsr = function(attack, decay, sustain, release)
return {
attack = attack * 1000,
decay = decay * 1000,
sustain = sustain,
release = release * 1000,
}
end,
makeVolume = function(args)
if type(args) == "number" then
local midiVolume = args
return midiVolume / 127
end
local volume = 1
if args.midi then
volume = volume * makeVolume(args.midi)
end
if args.dB then
volume = volume * 10^(args.dB / 20)
end
return volume
end,
})
if not chunk then
errf("Could not load the instrument definition file: %s",
err or "compilation failed")
os.exit(2)
end
local defs = chunk()
if type(defs) ~= "table" then
errf("The instrument definition file must return a table")
os.exit(2)
end
local instruments = {}
for i = 1, #defs, 1 do
local instrument = {}
instruments[i] = instrument
local def = defs[i]
checkInstrumentProperty(i, nil, def) { checks.type("table") }
instrument.waveType = checkInstrumentProperty(i, "waveType", def.waveType) {
checks.default("sine"),
checks.message("wave type %s is not recognized", checks.keyOf(waveTypes)),
}
local adsrDef = checkInstrumentProperty(i, "adsr", def.adsr) {
checks.allowNil(),
checks.type("table"),
}
if adsrDef then
local adsr = {}
instrument.adsr = adsr
adsr.attack = checkInstrumentProperty(i, "adsr.attack", adsrDef.attack) {
checks.required(),
checks.type("number"),
checks.finite(),
checks.nonNegative(),
}
adsr.decay = checkInstrumentProperty(i, "adsr.decay", adsrDef.decay) {
checks.required(),
checks.type("number"),
checks.finite(),
checks.nonNegative(),
}
adsr.sustain = checkInstrumentProperty(i, "adsr.sustain", adsrDef.sustain) {
checks.required(),
checks.type("number"),
checks.bounded01(),
}
adsr.release = checkInstrumentProperty(i, "adsr.release", adsrDef.release) {
checks.required(),
checks.type("number"),
checks.finite(),
checks.nonNegative(),
}
end
instrument.volume = checkInstrumentProperty(i, "volume", def.volume) {
checks.default(1),
checks.type("number"),
checks.bounded01(),
}
instrument.playMode = checkInstrumentProperty(i, "playMode", def.playMode) {
checks.default("poly"),
checks.type("string", "number"),
checks.cond(
function(value) return type(value) == "number" end,
checks.all {
checks.integer(),
checks.bounded(1, 254),
},
checks.keyOf(playModes)
)
}
end
return defs
end
local u32 = ">I4"
local u24 = ">I3"
local u16 = ">I2"
local u8 = ">I1"
local i8 = ">i1"
local function encodeHeader(output, tracks)
-- tempo
assert(output:write(u32:pack(math.floor(tracks.info.tempo * 1000))))
-- time signature
assert(output:write(u8:rep(2):pack(
tracks.info.timeSignature.numerator,
tracks.info.timeSignature.denominator
)))
-- ticks per quarter
assert(output:write(u32:pack(tracks.info.division)))
-- extensions
assert(output:write(u8:pack(0)))
-- track count
assert(output:write(u8:pack(#tracks)))
end
local function encodeEvent(output, event)
-- event kind
assert(output:write(u8:pack(eventKinds["note-press"])))
-- time
assert(output:write(u32:pack(event.onAt)))
-- key
assert(output:write(i8:pack(event.key - 60)))
-- velocity
assert(output:write(u8:pack(math.floor(event.velocity * 0xff + 0.5))))
-- duration
assert(output:write(u32:pack(event.offAt - event.onAt)))
end
local function encodeTrack(output, instrument, track)
-- wave type
assert(output:write(u8:pack(waveTypes[instrument.waveType])))
-- adsr envelope presence
assert(output:write(u8:pack(instrument.adsr and 0x01 or 0x00)))
if instrument.adsr then
-- adsr envelope settings
assert(output:write((u24 .. u24 .. u16 .. u24):pack(
math.floor(instrument.adsr.attack),
math.floor(instrument.adsr.decay),
math.floor(instrument.adsr.sustain * 0xffff + 0.5),
math.floor(instrument.adsr.release)
)))
end
-- volume
assert(output:write(u16:pack(math.floor(instrument.volume * 0xffff + 0.5))))
-- play mode
local playMode = instrument.playMode
if type(instrument.playMode) == "string" then
playMode = playModes[playMode]
end
assert(output:write(u8:pack(playMode)))
-- extensions
assert(output:write(u8:pack(0)))
-- event count
assert(output:write(u32:pack(#track)))
for _, event in ipairs(track) do
encodeEvent(output, event)
end
end
local function encode(output, instruments, tracks)
assert(output:write("sndc"))
encodeHeader(output, tracks)
for i = 1, #tracks, 1 do
encodeTrack(output, instruments[tracks[i].trackId], tracks[i])
end
end
local function parseArgs(...)
local args = {...}
local i = 1
while i <= #args do
local drop = true
local arg = args[i]
if arg:sub(1, 2) == "--" then
local key = arg:sub(3)
local value = true
local equalsPos = key:find("=")
if equalsPos then
value = key:sub(equalsPos + 1)
key = key:sub(1, equalsPos - 1)
end
args[key] = value
elseif arg:sub(1, 1) == "-" then
for c in arg:sub(2):gmatch(".") do
args[c] = true
end
else
drop = false
end
if drop then
table.remove(args, i)
else
i = i + 1
end
end
return args
end
local function printHelp(format, ...)
if format then
io.stderr:write(format:format(...) .. "\n")
end
local helpString = [[
Usage: midi [options...] --output=<path> --instr=<instrument-file> <midi-file>
Arguments:
<midi-file>
The path to a .mid file.
Options:
-h Show this help message.
--instr=<instrument-file>, --instruments=<instrument-file>
The path to an instrument definition file.
--output=<path>
The path to the output file.
]]
(message and io.stderr or io.stdout):write(helpString)
end
local function parseOption(optionName, optionValue, ty, default)
if not optionValue then
return default
end
if ty == "boolean" then
if type(optionValue) ~= "boolean" then
printHelp("The option %s does not accept values", optionName)
os.exit(1)
end
return optionValue
end
if type(optionValue) == "boolean" then
printHelp("The option %s requires a value", optionName)
os.exit(1)
end
if ty == "integer" or ty == "float" then
local value = tonumber(optionValue)
if not value then
printHelp("Invalid value for %s: expected %s", optionName, ty)
os.exit(1)
end
if ty == "integer" then
value = math.floor(value)
end
return value
elseif ty == "string" then
return optionValue
end
error("Unknown option type: " .. ty, 1)
end
local function popKey(tbl, key)
local value = tbl[key]
tbl[key] = nil
return value
end
local function any(...)
for i = 1, select("#", ...), 1 do
if select(i, ...) then
return (select(i, ...))
end
end
return nil
end
local function readOptions(args)
local options = {}
if parseOption("h", args.h, "boolean")
or parseOption("help", args.help, "boolean") then
printHelp()
os.exit(0)
end
if #args > 1 then
printHelp("Too many arguments provided")
os.exit(1)
end
options.input = parseOption("midi-file", table.remove(args, 1), "string", "-")
options.output = parseOption("output", popKey(args, "output"), "string")
options.instrumentFile = any(
parseOption("instr", popKey(args, "instr"), "string"),
parseOption("instruments", popKey(args, "instruments"), "string")
)
local unknownOption = next(args)
if unknownOption then
printHelp("Unknown option %s", unknownOption)
os.exit(1)
end
if not options.output then
printHelp("The output file was not provided (--output)")
os.exit(1)
elseif options.output == "-" then
printHelp("--output does not support the - value")
os.exit(1)
end
if not options.instrumentFile then
printHelp("No instrument definition file was provided (--instr)")
os.exit(1)
elseif options.instrumentFile == "-" then
printHelp("--instr does not support the - value")
os.exit(1)
end
return options
end
local function openFile(path, mode, purpose)
if path == "-" then
if mode:find("r") then
return io.stdin
else
return io.stdout
end
end
local f, err = io.open(path, mode)
if not f then
io.stderr:write(("Could not open %s for %s: %s\n"):format(
path, purpose, err or "unknown error"))
os.exit(1)
end
return f
end
local args = parseArgs(...)
local options = readOptions(args)
local input = openFile(options.input, "rb", "reading")
local instrumentFile = openFile(options.instrumentFile, "r", "reading")
local output = openFile(options.output, "wb", "writing")
local instruments =
loadInstrumentDefinitions(instrumentFile, options.instrumentFile)
local tracks = compileTracks(makeMidiParser(input))
if tracks.trackCount > #instruments then
io.stderr:write(("The instrument definition file has provided %d instruments but the MIDI file has %d\n"):format(
#instruments, tracks.trackCount))
os.exit(2)
end
print("Track to instrument mapping:")
for i, track in ipairs(tracks) do
printf("Track #%d -> instrument #%d", i, track.trackId)
end
encode(output, instruments, tracks)
if input ~= io.stdin then
input:close()
end
if output ~= io.stdout then
output:close()
end

The MIDI compiler produces a file of the following format.

  • Magic bytes: the value sndc.
  • Tempo: u32 (the length of 1 quarter in milliseconds).
  • Time signature:
    • Numerator: u8.
    • Denominator: u8.
  • Ticks per quarter: u32 (the number of ticks per quarter note).
  • Extension count: u8.
  • Extensions:
    • Extension name length: u16.
    • Extension name: u8[extension_name_length].
    • Extension data length: u32.
    • Extension data: u8[extension_data_length].
  • Track count: u8.
  • Tracks:
    • Wave type: u8.
      • 0x00 — sine
      • 0x01 — sawtooth
      • 0x02 — square
      • 0x03 — triangle
      • 0x04 — noise
    • ADSR envelope presence: u8.
      • 0x00 — no envelope set
      • 0x01 — an ADSR envelope is set
    • ADSR envelope settings (if ADSR envelope presence is 0x01).
      • Attack: u24. Specified in milliseconds.
      • Decay: u24. Specified in milliseconds.
      • Sustain: u16.
        • 0xffff — 100% sustain
        • 0x0000 — 0% sustain
      • Release: u24. Specified in milliseconds.
    • Volume: u16. 0xffff stands for 100%.
    • Play mode: u8.
      • 0x00 — mono
      • 0xff — polyphonic
      • other values: glide depth
        • The envelope is not retriggered for consecutive notes less than the provided number of semitones apart.
    • Extension count: u8.
    • Extensions:
      • Extension name length: u16.
      • Extension name: u8[extension_name_length].
      • Extension data length: u32.
      • Extension data: u8[extension_data_length].
    • Event count: u32.
    • Events:
      • Event kind: u8.
        • 0x00 — note-press
      • Event data:
        • Time: u32 (in ticks, non-decreasing).
        • note-press events:
          • Key: i8.
            • In semitones. C4 is mapped to zero.
          • Velocity: u8.
            • Additional note attenuation. 0x00 is silent, 0xff keeps the track volume.
          • Duration: u32.
local master = makeVolume { dB = 1.8 }
return {
{
waveType = "sine",
adsr = makeAdsr(0, 0.25, 1, 0.343),
volume = makeVolume { dB = -28.8 } * master,
playMode = "mono",
},
{
waveType = "sawtooth",
adsr = makeAdsr(0, 0.563, 0.5886, 0.192),
volume = makeVolume { dB = -19.8 } * master,
playMode = "poly",
},
{
waveType = "sawtooth",
adsr = makeAdsr(0.009, 0.17, 0.9014, 0.034),
volume = makeVolume { dB = -12.4 } * master,
playMode = "poly",
},
{
waveType = "square",
adsr = makeAdsr(0, 0.231, 0.7771, 0.052),
volume = makeVolume { dB = -18.6 } * master,
playMode = "poly",
},
{
waveType = "triangle",
adsr = makeAdsr(0, 0.25, 1, 0.031),
volume = makeVolume { dB = -12.5 } * master,
playMode = "poly",
},
{
waveType = "noise",
adsr = makeAdsr(0, 0.198, 0, 0),
volume = makeVolume { dB = -22.1 } * master,
playMode = "poly",
},
{
waveType = "noise",
adsr = makeAdsr(0, 0.198, 0, 0.1),
volume = makeVolume { dB = -9.3 } * master,
playMode = "poly",
},
}
@Fingercomp

Copy link
Copy Markdown
Author

Thanks for scrolling to the bottom. As a treat, here's another YouTube demo video. It's basically the same program you've just scrolled past, or rather its earlier version.

Unlike the video's author, you won't need to type in the notes yourself if you just use the MIDI converter.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment