|
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("") |
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.