Skip to content

Instantly share code, notes, and snippets.

@Fingercomp
Last active December 26, 2023 18:11
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Fingercomp/62137202ec5f607e3f20ca227f6a029c to your computer and use it in GitHub Desktop.
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",
},
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment