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