diff --git a/Assets/FiraCode-LICENSE.txt b/Assets/FiraCode-LICENSE.txt new file mode 100644 index 0000000..805e0b3 --- /dev/null +++ b/Assets/FiraCode-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright (c) 2014, The Fira Code Project Authors (https://github.com/tonsky/FiraCode) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/Assets/FiraCode-Medium.ttf b/Assets/FiraCode-Medium.ttf new file mode 100755 index 0000000..2c0ecdf Binary files /dev/null and b/Assets/FiraCode-Medium.ttf differ diff --git a/Assets/colors.lua b/Assets/colors.lua new file mode 100644 index 0000000..5949099 --- /dev/null +++ b/Assets/colors.lua @@ -0,0 +1,9 @@ +return { + background = "#181819", -- "#282829", + cursor = "#ff00d2", + text_title = "#32ecff", + text_value = "#fafafa", + text_default = "#bcbcba", + text_info = "#6060be", + text_empty = "#5E5E68", +} \ No newline at end of file diff --git a/assets/music.mml b/Examples/AmigaBASIC-Demo.txt similarity index 96% rename from assets/music.mml rename to Examples/AmigaBASIC-Demo.txt index 9d140d4..83785e0 100644 --- a/assets/music.mml +++ b/Examples/AmigaBASIC-Demo.txt @@ -2,20 +2,22 @@ #COMPOSER Johann Sebastian Bach #PROGRAMMER marc2o -@v1 = { 2 80 60 50 } +@env1 = { 2 32 72 50 } + +t120 A -t120 @3 o4 l6 @v1 +o4 l6 @env1 @02 r6gab>dcceddgf+gdcdedccdcceddgf+gddcdgddl2cd1dc1cccdcceddgf+gddcdcceddgf+gdcdedccdcceddgf+gddccccdcceddgf+gddcdcceddgf+gdcdedccdcceddgf+gddcdgdc+dccdcdcceddfeeag+aeccfedccear3l2dl6ecdegfgb-aa>ccccdcceddgf+gdcdedccccdcceddgf+gddcdgdafddedcdccdcceddgf+gdcdedccdcceddgf+gddcda3ga3f+d3f+g3f+g3ab3ab3ge3ga3f+g3ecgb3>dg3dl2cdg3f+g3ab3ab3ge3de3f+g3de3f+a3ga3f+d3f+g3f+g3ab3ab3ge3ga3f+g3ecl2b1>cd1dc1ccd1c8da3ga3f+d3f+g3f+g3ab3ab3ge3ga3f+g3ecgb3>dg3c1cc8d8c3c3d>l2c1de1edd4e8f8dl6c3c3dc3c3d3cd1dcccd1c8dg3dd3b>c2c3da3ga3f+d3f+g3f+g3ab3ab3ge3ga3f+g3ecgeecdf+dgecedcc+dg6d3f+ef+edef+gdr3d6d3d6r3d6d3d6gf+ebecdgeecdf+dgecedcc+df+ef+edef+gdr3d6d3d6d6d3d6gf+ebecdgeecdf+dgecedcc+dg6f+3e6dca4g4f4e4dea>fdcfedd+eab>cd6geecdr3d6d3d6d6d3d6gf+ebecdgeecdf+dgecedcc+ddccf+gegeceddr2b3g6dd3d6f+3a6f+3d6d6ddegdcdcdegedccf+gegeceddd3df+3af+3ddl2ddegdcdcdegedccf+gegeceddr3g6d3e6f+ddeaaag+ef+afcdf+eccagdf+ggegfaggr3d6d3d6ddegedccl6d3df+3af+3ddl2ddegdcdcg6d3g6dr3g6e3c6d6e6a6f+3d6ddegedf+gege c + +C q5 l8 o2 v100 +c. > c. < c c. > c. < c +c. > c. < c c. > c. < c +c. > c. < c c. > c. < c +c. > c. < c c. > c. < c + +D o3 l8 @env1 +r1 +c c c c c c c c +c c c c c c c c + +E o1 l8 v100 +r1 r1 +@env3 v127 c r @env1 v80 c @env3 v127 c +@env3 v127 c r @env1 v80 c @env3 v127 c +@env3 v127 c r @env1 v80 c @env3 v127 c +@env3 v127 c r @env1 v80 c @env3 v127 c diff --git a/Modules/Music.lua b/Modules/Music.lua new file mode 100644 index 0000000..1819e32 --- /dev/null +++ b/Modules/Music.lua @@ -0,0 +1,561 @@ +music = { + tracks = { + current_track = "A", + info = { + A = {}, + B = {}, + C = {}, + D = {}, + E = {} + }, + data = { + A = {}, -- pulse wave (with duty cycle) + B = {}, -- pulse wave (with duty cycle) + C = {}, -- triangle wave (no volume control, only v0 or v1) + D = {}, -- sawtooth wave + E = {} -- white noise + } + }, + meta = { + title = "", + composer = "", + programmer = "", + copyright = "", + }, + timebase = 480, + tempo = 80, -- beats per minute + base_frequency = 440, + sample_rate = 11025, + bits = 8, + channels = 1, + amplitude = 1.0, + envelopes = { + default = { + a = 1 / 60 * 11025, -- duration * sample_rate + d = 32 / 60 * 11025, + s = 80 / 0x7F, -- volume 0..127 -> 0.0..1.0 + r = 48 / 60 * 11025 + } + }, + audio = { + source = nil, + sound_data = nil + }, + mml = {} +} + +-- pulse wave +function music:PULSE(sample_rate, frequency, duty_cycle) --> function() + -- number of points in dataset + local npoints = sample_rate / frequency + local duty_cycle = duty_cycle or 0.5 + + return function(i) + i = i % npoints + 1 + return i < (npoints * duty_cycle) and -1 or 1 + end +end + +function music:TRIANGLE(sample_rate, frequency) --> function() + local npoints = sample_rate / frequency + + return function(i) + i = i % npoints + 1 + local step = 4 / npoints + return i < (npoints / 2) and step * (i - 1) - 1 or step * ((i - 1) - npoints / 2) + end +end + +function music:SAWTOOTH(sample_rate, frequency) --> function() + local npoints = sample_rate / frequency + + return function(i) + i = i % npoints + 1 + local step = 4 / npoints + return i < npoints - 1 and step * (i - 1) - 1 or -1 + end +end + +function music:NOISE(sample_rate, frequency) --> function() + local npoints = sample_rate / frequency + + return function() + return math.random(-1.0, 1.0) + end +end + +function music:init() + if self.audio.source then self:stop() end + self.audio = { source = nil, sound_data = nil } + self.meta.title = "" + self.meta.composer = "" + self.meta.programer = "" + self.meta.copyright = "" + self.tracks.data.A = {} + self.tracks.info.A = {} + self.tracks.data.B = {} + self.tracks.info.B = {} + self.tracks.data.C = {} + self.tracks.info.C = {} + self.tracks.data.D = {} + self.tracks.info.D = {} + self.tracks.data.E = {} + self.tracks.info.E = {} +end +function music:play() + if self.audio.source then love.audio.play(self.audio.source) end +end +function music:stop() + love.audio.stop() +end +function music:is_playing() --> bool + if self.audio.source then + return self.audio.source:isPlaying() + else + return false + end +end +function music:pause() + if self:is_playing() and self.audio.source then + love.audio.pause(self.audio.source) + elseif not self:is_playing() and self.audio.source then + love.audio.play(self.audio.source) + end +end +function music:get_current_sample() --> value + local position = self.audio.source:tell("seconds") + position = math.floor(position * self:get_sample_rate()) + return self.audio.sound_data:getSample(position) +end + +function music:set_info(keyword, value) + if string.lower(keyword) == "title" then + self.meta.title = value + elseif string.lower(keyword) == "composer" then + self.meta.composer = value + elseif string.lower(keyword) == "programmer" or string.lower(keyword) == "programer" then + self.meta.programmer = value + elseif string.lower(keyword) == "copyright" then + self.meta.copyright = value + elseif string.lower(keyword) == "timebase" then + self.timebase = tonumber(value) + else + -- keyword not recognized + -- ignore + end +end + +function music:get_used_voices() + return { + A = next(self.tracks.data.A) and true or false, + B = next(self.tracks.data.B) and true or false, + C = next(self.tracks.data.C) and true or false, + D = next(self.tracks.data.D) and true or false, + E = next(self.tracks.data.E) and true or false, + } +end + +function music:get_title() --> string + return self.meta.title +end + +function music:get_composer() --> string + return self.meta.composer +end + +function music:get_programmer() --> string + return self.meta.programmer +end + +function music:get_copyright() --> string + return self.meta.copyright +end + +function music:get_timebase() --> number + return self.timebase +end + +function music:get_sample_rate() --> number + return self.sample_rate +end + +function music:define_envelope(name, attack, decay, sustain, release) + self.envelopes[name] = { + a = attack / 60 * self.sample_rate, + d = decay / 60 * self.sample_rate, + s = sustain / 0x7F, + r = release / 60 * self.sample_rate + } +end +function music:set_envelope(name) + local track = track or self:get_track() + self.tracks.info[track].envelope = name +end +function music:get_envelope(track) --> string + local envelope = self.tracks.info[track].envelope or "default" + return envelope +end + +function music:set_track(letter) + self.tracks.current_track = letter +end +function music:get_track() --> string + return self.tracks.current_track +end + +function music:set_tempo(bpm) + self.tempo = bpm +end +function music:get_tempo() --> number + return self.tempo +end + +function music:set_volume(volume, track) + local track = track or self:get_track() + if track == "C" then + volume = tostring(volume / volume) == tostring(0/0) and 0 or 1.0 + else + volume = volume / 0x7F + end + self.tracks.info[track].volume = volume +end +function music:get_volume(track) --> number 0..127 + local volume = self.tracks.info[track].volume or 80 / 0x7F + if track == "C" then + volume = tostring(volume / volume) == tostring(0/0) and 0 or 1.0 + end + return volume +end + +function music:shift_octave(shift, track) + local track = track or self:get_track() + local octave = self:get_octave(track) + octave = octave + shift + self:set_octave(octave) +end +function music:set_octave(octave, track) + local track = track or self:get_track() + self.tracks.info[track].octave = octave +end +function music:get_octave(track) --> number + local octave = self.tracks.info[track].octave or 4 + return octave +end + +function music:set_length(length, track) + local track = track or self:get_track() + self.tracks.info[track].length = length +end +function music:get_length(track) --> number + local length = self.tracks.info[track].length or 4 + return length +end + +function music:set_quantization(q, track) + local track = track or self:get_track() + self.tracks.info[track].quantization = q +end +function music:get_quantization(track) --> number + local q = self.tracks.info[track].quantization or 8 + return q +end + +function music:get_track_duration(track) + local track = track or self:get_track() + local track_duration = self.tracks.info[track].track_duration or 0 + return track_duration +end +function music:set_track_duration(duration, track) + local track = track or self:get_track() + local track_duration = self:get_track_duration(track) + track_duration = track_duration + duration + self.tracks.info[track].track_duration = track_duration +end + +function music:note(note, accident, value, dot) + local track = self:get_track() + local duration = 0 + + if value then + duration = 4 / value + else + duration = 4 / self:get_length(track) + end + duration = duration * (60 / self:get_tempo()) + if dot then duration = duration * 1.5 end + + self:set_track_duration(duration, track) + + self:send({ + note = note, + accident = accident, + duration = duration, + octave = self:get_octave(track), + volume = self:get_volume(track), + dcycle = self.tracks.info[track].dcycle, + envelope = self:get_envelope(track), + quantization = self:get_quantization(track), + track = track + }) +end +function music:send(message) + table.insert(self.tracks.data[message.track], message) +end + + +-- AUDIO RENDERER + +function music:render_audio() + local song_duration = 0 + local song_voices = 0 + local song_sample_count = 0 + local previous_sound = {} + + for track, _ in pairs(self.tracks.info) do + if song_duration < self:get_track_duration(track) then + song_duration = self:get_track_duration(track) + 1 + end + if self:get_track_duration(track) > 0 then song_voices = song_voices + 1 end + end + + local song_samples = song_duration * self:get_sample_rate() + + self.audio.sound_data = love.sound.newSoundData(song_samples, self:get_sample_rate(), 8, 1) + + local notes = { c = -9, d = -7, e = -5, f = -4, g = -2, a = 0, b = 2, h = 2 } + + for key, track in pairs(self.tracks.data) do + song_sample_count = 0 + previous_sound = {} + + for _, message in ipairs(track) do + + local waveform = nil + local frequency = 0 + local envelope = 0 + local samples = 0 + + + if message.duration > 0 then + samples = message.duration * self.sample_rate + end + + if message.note:match("[abcdefgh]") then + local pitch = notes[message.note] + + if message.accident then + if message.accident == "#" or message.accident == "+" then + pitch = pitch + 1 + elseif message.accident == "-" then + pitch = pitch - 1 + end + end + + pitch = pitch + 12 * (message.octave - 4) + frequency = self.base_frequency * 2 ^ (pitch / 12) + + end + + if message.track:match("[AB]") then + waveform = self:PULSE(self.sample_rate, frequency, message.dcycle) + elseif message.track == "C" then + waveform = self:TRIANGLE(self.sample_rate, frequency) + elseif message.track == "D" then + waveform = self:SAWTOOTH(self.sample_rate, frequency) + elseif message.track == "E" then + waveform = self:NOISE(self.sample_rate, frequency) + end + + for i = 0, (samples - 1) do + + if message.note:match("[abcdefgh]") then + sample = waveform(i) + + if not (message.track == "C") then + if i <= self.envelopes[message.envelope].a then + envelope = i / (self.envelopes[message.envelope].a - 1) + elseif i > self.envelopes[message.envelope].a and i <= self.envelopes[message.envelope].d then + envelope = self.envelopes[message.envelope].s + (1 - self.envelopes[message.envelope].s) * (1 - (i / self.envelopes[message.envelope].d)) + elseif i > self.envelopes[message.envelope].d then + envelope = self.envelopes[message.envelope].s + end + else + envelope = 1.0 + end + if i >= samples / 8 * message.quantization then + sample = 0 + end + + elseif message.note:match("[prw]") then + if previous_sound.message and previous_sound.message.note:match("[abcdefgh]") then + sample = previous_sound.waveform(i) + if i <= self.envelopes[message.envelope].r then + envelope = self.envelopes[message.envelope].s * (1 - i / self.envelopes[message.envelope].r) + elseif i > self.envelopes[message.envelope].r then + sample = 0 + end + else + sample = 0 + end + end + + local modifiers = self.amplitude * message.volume * envelope + if modifiers > 1.0 then modifiers = 1.0 end -- ??? + + sample = math.tanh(self.audio.sound_data:getSample(song_sample_count) + sample * modifiers / song_voices) + self.audio.sound_data:setSample(song_sample_count, sample) + song_sample_count = song_sample_count + 1 + end + + previous_sound = { + message = message, + waveform = waveform + } + end + end + + self.audio.source = love.audio.newSource(self.audio.sound_data, "static") +end + +-- THE PARSER + +function music:parse_mml(mml) --> bool + local success = true + math.randomseed(os.time()) + + self.mml = mml + + for _, line in pairs(mml) do + local end_of_line = false + + local i = 1 + + repeat + local cmd = string.match(string.sub(line, i), "^[%a<>&#@/:;%[%]]") + if cmd then + + if cmd:match(";") then + -- ; comment + -- do nothing + end_of_line = true + + elseif cmd:match("#") and i == 1 then + -- # keyword + local keyword, value = string.match(string.sub(line, i), "#(%a+)%s+(.+)") + value = value:gsub("(;.+)", "") + end_of_line = true + self:set_info(keyword, value) + + elseif cmd:match("@") then + -- @ macro + local name, args = string.match(string.sub(line, i), "@([%w]+)%s-=%s+(.+)") + + if name then + -- macro definition + name = name:lower() + if name:match("env%d+") then + -- envelope macro + local attack, decay, sustain, release = args:match("(%d+)%D+(%d+)%D+(%d+)%D+(%d+)%D+") + self:define_envelope(name, attack, decay, sustain, release) + end + end_of_line = true + + else + name = string.match(string.sub(line, i), "@([%w]+)[%s%a<>&#@/:;%[%]]?") + name = name:lower() + + if name:match("[%d%d]") and tonumber(name) then + -- @00..03 duty cylce (only pulse wave channels A and B) + local index = tonumber(name) + 1 + local dcycle = { 0.125, 0.25, 0.5, 0.75 } + if index > 0 and index <= #dcycle then + self.tracks.info[self:get_track()].dcycle = dcycle[index] + end + + elseif name:match("env%d+") then + -- @env envelopes + self:set_envelope(name) + + elseif name:match("arp%d+") then + -- @arp arpeggios + -- to do + + elseif name:match("vib%d+") then + -- @vib vibratos + -- to do + + end + i = i + name:len() + args = "" + end + + elseif cmd:match("%u") then + -- A..E channel name + self:set_track(cmd) + + elseif cmd:match("[<>&]") then + -- <, >, & octave shifts and tie + if cmd:match("[<>]") then + self:shift_octave(cmd == "<" and -1 or 1) + end + + elseif cmd:match("[%[%]]") then + -- [, ] loop + -- to do + if cmd == "[" then + -- [ begin loop + elseif cmd == "]" then + -- ]n end loop, repeat n times, default = 2 + local ntimes = string.match(string.sub(line, i), ":(%d+)[%D]") or "2" + end + + elseif cmd:match("[abcdefghlopqrtvw]") then + local args = string.match(string.sub(line, i + 1), "^([%+%-#%d%.]+)[%s<>&#@/:;%[%]]?") or "" + + local accident = args:match("[%-%+#]") + local value = tonumber(args:match("%d+")) + local dot = args:match("%.") + + if cmd:match("[abcdefgh]") then + -- a..h notes, b can be used instead of h + self:note(cmd, accident, value, dot) + + elseif cmd == "l" then + -- l(ength) + self:set_length(value) + + elseif cmd == "o" then + -- o(ctave) + self:set_octave(value) + + elseif cmd:match("[prw]") then + -- p(ause), r(est) + -- w(ait) rest without silencing previous note + self:note(cmd, accident, value, dot) + + elseif cmd == "q" then + -- q(uantize) + self:set_quantization(value) + + elseif cmd == "t" then + -- t(empo) + self:set_tempo(value) + + elseif cmd == "v" then + -- v(olume) + self:set_volume(value) + + end + end + end + + i = i + 1 + if i > string.len(line) then end_of_line = true end + until end_of_line + + end + + self:render_audio() + + return success +end diff --git a/Modules/NamedColorPalette.lua b/Modules/NamedColorPalette.lua new file mode 100644 index 0000000..d76e193 --- /dev/null +++ b/Modules/NamedColorPalette.lua @@ -0,0 +1,46 @@ +--[[ + ___ + ______ ___ ___ ___ / \ ___ + _/ \_/ \_/ _ \_/ \_-- /- \ + / / / / / / /__/ /__/ __/ / / + /___/__/__/\__/\_/___/ \____/ \____/ + (c) 2020 – 2022 marc2o \______/ + https://marc2o.github.io + +--]] + +NamedColorPalette = { + colors = {} +} + +function NamedColorPalette:new(o) + o = o or {} + setmetatable(o, self) + self.__index = self + return o +end + +--- use list with hex strings such as "#ff0099" +function NamedColorPalette:create(list) + for key, value in pairs(list) do + self.colors[key] = { + tonumber('0x' .. value:sub(2, 3)) / 255, + tonumber('0x' .. value:sub(4, 5)) / 255, + tonumber('0x' .. value:sub(6, 7)) / 255 + } + end +end + +---returns a color in a format suitable for love.graphics.setColor() +function NamedColorPalette:get_color(name) + return { + self.colors[name][1], + self.colors[name][2], + self.colors[name][3], + } +end + +--- retrieve number of colors in palette +function NamedColorPalette:get_number_of_colors() + return #self.colors +end diff --git a/Modules/WriteAiff.lua b/Modules/WriteAiff.lua new file mode 100644 index 0000000..b432a90 --- /dev/null +++ b/Modules/WriteAiff.lua @@ -0,0 +1,162 @@ +--[[ + ___ + ______ ___ ___ ___ / \ ___ + _/ \_/ \_/ _ \_/ \_-- /- \ + / / / / / / /__/ /__/ __/ / / + /___/__/__/\__/\_/___/ \____/ \____/ + (c) 2020 – 2022 marc2o \______/ + https://marc2o.github.io + +--]] + +-- WORK IN PROGRESS +function math.clamp(low, n, high) + return math.min(math.max(n, low), high) +end + +function math.numberToBytes(number, numberOfBytes) + local byteChars = "" + local bytes = {} + if numberOfBytes == 4 then + table.insert(bytes, math.floor((number % 2^32) / 2^24)) + end + if numberOfBytes >= 3 then + table.insert(bytes, math.floor((number % 2^24) / 2^16)) + end + if numberOfBytes >= 2 then + table.insert(bytes, math.floor((number % 2^16) / 2^8)) + end + if numberOfBytes >= 1 then + table.insert(bytes, math.floor((number % 2^8))) + end + for i = 1, #bytes do + byteChars = byteChars .. string.char(bytes[i]) + end + return byteChars +end + + +aiff = {} + +function aiff:createFile(filename) + --return io.open(filename .. ".aiff", "w") + return love.filesystem.newFile(filename .. ".aiff", "w") +end + +function aiff:writeFile(file, args, ...) + local soundData = args.soundData + local sampleRate = soundData:getSampleRate() + local sampleSize = soundData:getBitDepth() + local numChannels = soundData:getChannelCount() + local numSampleFrames = soundData:getSampleCount() / numChannels + local dataSize = soundData:getDuration() * sampleRate * sampleSize / 8 + file:write("FORM????AIFF") + + file:write(self:getChunk({ + ID = "COMM", + dataSize = 18, + numChannels = numChannels, + numSampleFrames = numSampleFrames, + sampleSize = sampleSize, + sampleRate = sampleRate + })) + + file:write(self:getChunk({ + ID = "SSND", + dataSize = dataSize + })) + + self:writePCM({ + file = file, + soundData = soundData, + sampleSize = sampleSize + }) + + file:write(self:getChunk({ + ID = "NAME", + text = args.title + })) + + file:write(self:getChunk({ + ID = "AUTH", + text = args.composer + })) + + local fileInfo = love.filesystem.getInfo(file:getFilename()) + local fileSize = fileInfo.size + file:seek(4) + --local fileSize = file:seek() + --file:seek("set", 4) + file:write(math.numberToBytes(fileSize - 8, 4)) +end + +function aiff:closeFile(file) + file:close() + file = nil +end + +function aiff:writePCM(args, ...) + local size = 2^args.sampleSize / 2 + for i = 0, args.soundData:getSampleCount() - 1 do + local sample = args.soundData:getSample(i) * size + sample = math.clamp(-size, sample, size - 1) + sample = math.numberToBytes(sample, args.sampleSize / 8) + args.file:write(sample) + end +end + +function aiff:getChunk(args, ... ) + if args.ID == "FORM" then + return + "FORM" .. + math.numberToBytes(args.dataSize, 4) .. + "AIFF" + end + + if args.ID == "COMM" then + local nfreq = string.char(0x40) + if args.sampleRate == 11025 then + nfreq = nfreq .. string.char(0x0c) + elseif args.sampleRate == 22050 then + nfreq = nfreq .. string.char(0x0d) + else + nfreq = nfreq .. string.char(0x0e) + end + nfreq = nfreq .. string.char(0xac) .. string.char(0x44) + for i = 1, 6 do + nfreq = nfreq .. string.char(0) + end + return + "COMM" .. + math.numberToBytes(args.dataSize, 4) .. + math.numberToBytes(args.numChannels, 2) .. + math.numberToBytes(args.numSampleFrames, 4) .. + math.numberToBytes(args.sampleSize, 2) .. + nfreq --.. + --"NONE" .. + --"not compressed" + end + + if args.ID == "SSND" then + return + "SSND" .. + math.numberToBytes(args.dataSize, 4) .. + math.numberToBytes(0, 4) .. -- offset + math.numberToBytes(0, 4) -- block size + end + + if args.ID == "NAME" then + return + "NAME" .. + math.numberToBytes(string.len(args.text), 4) .. + args.text + end + + if args.ID == "AUTH" then + return + "AUTH" .. + math.numberToBytes(string.len(args.text), 4) .. + args.text + end +end + diff --git a/Modules/WriteMidi.lua b/Modules/WriteMidi.lua new file mode 100644 index 0000000..0558d09 --- /dev/null +++ b/Modules/WriteMidi.lua @@ -0,0 +1,37 @@ +--[[ + ___ + ______ ___ ___ ___ / \ ___ + _/ \_/ \_/ _ \_/ \_-- /- \ + / / / / / / /__/ /__/ __/ / / + /___/__/__/\__/\_/___/ \____/ \____/ + (c) 2020 – 2022 marc2o \______/ + https://marc2o.github.io + +--]] + +-- Event Types +local NOTE_OFF = 0x80 +local NOTE_ON = 0x90 +local POLY_AFTERTOUCH = 0xa0 +local CONTROL_CHANGE = 0xb0 +local PROGRAM_CHANGE = 0xc0 +local CHANNEL_AFTERTOUCH = 0xd0 +local PITCH_BEND = 0xe0 +local SYSTEM_EXCLUSIVE = 0xf0 + +-- Meta Event types +local TEMPO = 0x51; +local END_OF_TRACK = 0x2f; +local SEQUENCE_NUMBER = 0x00; +local TEXT_EVENT = 0x01; +local COPYRIGHT_NOTICE = 0x02; +local SEQUENCE_NAME = 0x03; +local INSTRUMENT_NAME = 0x04; +local LYRIC = 0x05; +local MARKER = 0x06; +local CUEPOINT = 0x07; +local CHANNEL_PREFIX = 0x20; +local SMPTE_OFFSET = 0x54; +local TIME_SIGNATURE = 0x58; +local KEY_SIGNATURE = 0x59; +local SEQUENCER_SPECIFIC = 0x74; diff --git a/assets/screenshot.png b/assets/screenshot.png deleted file mode 100644 index 056d262..0000000 Binary files a/assets/screenshot.png and /dev/null differ diff --git a/assets/test.mml b/assets/test.mml deleted file mode 100644 index 2f01648..0000000 --- a/assets/test.mml +++ /dev/null @@ -1,20 +0,0 @@ -#TITLE test tune -#COMPOSER marc2o - -@v1 = { 1 10 20 30 } -@v2 = { 2 20 30 40 } -@v3 = { 2 5 10 10 } -@v4 = { 1 20 90 20 } - -t200 - -A l8 o5 @v1 @2 v40 -A creedrffeegrffar ggeefrddcc
crrr -A creedrffeegrffar ggeefrddcc
crrr - -B l2 o3 @2 @v2 v80 -B c. c4 < a. a4 > d. d4 f. f4 -B e. e4 d. d4 < a. a4 g. g - -C l2 o2 @v3 v100 -C @1 c @5 c @1 c4c4 @5 c @1 c @5 c @1 c4c4 @5 c @1 c @5 c @1 c @5 c @1 c @5 c @1 c @5 c diff --git a/conf.lua b/conf.lua index 46a56a4..f40ff16 100644 --- a/conf.lua +++ b/conf.lua @@ -1,14 +1,14 @@ function love.conf(t) t.identity = nil -- The name of the save directory (string) - t.version = "11.3" -- The LÖVE version this game was made for (string) + t.version = "11.4" -- The LÖVE version this game was made for (string) t.console = false -- Attach a console (boolean, Windows only) t.accelerometerjoystick = true -- Enable the accelerometer on iOS and Android by exposing it as a Joystick (boolean) t.gammacorrect = false -- Enable gamma-correct rendering, when supported by the system (boolean) - t.window.title = "Music" -- The window title (string) + t.window.title = "App1" -- The window title (string) t.window.icon = nil -- Filepath to an image to use as the window's icon (string) - t.window.width = 800 -- The window width (number) 768 - t.window.height = 480 -- The window height (number) 432 + t.window.width = 768 -- The window width (number) 768 + t.window.height = 432 -- The window height (number) 432 t.window.borderless = false -- Remove all border visuals from the window (boolean) t.window.resizable = false -- Let the window be user-resizable (boolean) t.window.minwidth = 1 -- Minimum window width if the window is resizable (number) diff --git a/main.lua b/main.lua index 8fd86c4..c6949c1 100644 --- a/main.lua +++ b/main.lua @@ -4,267 +4,267 @@ _/ \_/ \_/ _ \_/ \_-- /- \ / / / / / / /__/ /__/ __/ / / /___/__/__/\__/\_/___/ \____/ \____/ - (c) 2021 marc2o \______/ + (c) 2020 – 2022 marc2o \______/ https://marc2o.github.io --]] -function __ERROR(msg) - local success = love.window.showMessageBox( "ERROR", msg, "info", true ) -end - -require("modules.synth") -require("modules.aiff") +VERSION = "0.2.0" -prettyTime = "" -time = 0 -startTime = 0 -pauseTime = 0 -saveOnExit = false +require("Modules.NamedColorPalette") +require("Modules.Music") +require("Modules.WriteAiff") -lines = { - width = love.graphics.getWidth(), - height = love.graphics.getHeight(), - number = 16, - delay = 60, - timer = 60, - limit = 0, - speed = 240, - counter = 240, - side = 1, - sides = { "top", "right", "bottom", "left" }, - getSide = function (n) - if n > #lines.sides then n = #lines.sides - n end - if n < 1 then n = #lines.sides + n end - return lines.sides[n] - end, - draw = function (side, mode) - side = lines.getSide(side) - for i = 1, lines.number do - if mode == "draw" then - if i <= lines.limit then - love.graphics.setColor(0.8, 0.8, 0.8) - else - love.graphics.setColor(0.1, 0.1, 0.1) - end - else - if i <= lines.limit then - love.graphics.setColor(0.2, 0.2, 0.2) - else - love.graphics.setColor(0.6, 0.6, 0.6) - end - end - local x1, y1, x2, y2 - if side == "top" then - x1 = 1 + (i - 1) * lines.width / lines.number - y1 = 1 - x2 = lines.width - y2 = 1 + (i - 1) * lines.height / lines.number - elseif side == "right" then - x1 = lines.width - y1 = 1 + (i - 1) * lines.height / lines.number - x2 = lines.width - (i - 1) * lines.width / lines.number - y2 = lines.height - elseif side == "bottom" then - x1 = lines.width - (i - 1) * lines.width / lines.number - y1 = lines.height - x2 = 1 - y2 = lines.height - (i - 1) * lines.height / lines.number - elseif side == "left" then - x1 = 1 - y1 = lines.height - (i - 1) * lines.height / lines.number - x2 = 1 + (i - 1) * lines.width / lines.number - y2 = 1 - end - love.graphics.line(x1, y1, x2, y2) - if mode == "draw" then lines.timer = lines.timer - 1 end - if lines.timer < 1 then - lines.timer = lines.delay - if lines.limit < lines.number then lines.limit = lines.limit + 1 end - end - end - end +local visualizer = "" +local font = nil +local t_ui = { + keycode_esc = { + color = "text_empty", + text = "[Esc] Quit  [Tab] Play/Pause  [F1] Export AIFF", + x = 16, + y = 400 - 40 + }, + song_title = { + color = "text_title", + text = "SONG INFO", + x = 16, + y = 16 + }, + title_label = { + color = "text_info", + text = "title:", + x = 16, + y = 40 + }, + title_text = { + color = "text_default", + text = "Untitled", + x = 144, + y = 40 + }, + composer_label = { + color = "text_info", + text = "composer:", + x = 16, + y = 64 + }, + composer_text = { + color = "text_default", + text = "John Doe", + x = 144, + y = 64 + }, + programmer_label = { + color = "text_info", + text = "programmer:", + x = 16, + y = 88 + }, + programmer_text = { + color = "text_default", + text = "John Doe", + x = 144, + y = 88 + }, + copyright_label = { + color = "text_info", + text = "copyright:", + x = 16, + y = 112 + }, + copyright_text = { + color = "text_default", + text = "John Doe", + x = 144, + y = 112 + }, + voices_label = { + color = "text_info", + text = "voices:", + x = 16, + y = 136 + }, + voice_A_text = { + color = "text_empty", + text = "A", + x = 144, + y = 136 + }, + voice_B_text = { + color = "text_empty", + text = "B", + x = 160, + y = 136 + }, + voice_C_text = { + color = "text_empty", + text = "C", + x = 176, + y = 136 + }, + voice_D_text = { + color = "text_empty", + text = "D", + x = 192, + y = 136 + }, + voice_E_text = { + color = "text_empty", + text = "E", + x = 208, + y = 136 + }, } -function drawLines() - lines.draw(lines.side - 1, "erase") - lines.draw(lines.side, "draw") +---------------------------------- +-- LÖVE BASE FUNCTIONS +---------------------------------- - lines.counter = lines.counter - 1 - if lines.counter < 1 then - lines.counter = lines.speed - lines.limit = 0 - lines.side = lines.side + 1 - if lines.side > #lines.sides then - lines.side = 1 - end +function love.update(dt) + if dt < 1/60 then + love.timer.sleep(1/60 - dt) end + + NEXT_t = NEXT_t + MIN_dt - if timeElapsed <= 0 then - currentSample = math.floor(time * synth.sampleRate) - timeElapsed = 5 - end - timeElapsed = timeElapsed - 1 - for i = 0, love.graphics.getWidth(), 4 do - local sample = 0 - if currentSample + i + 20 < totalSamples then - for s = i, i + 20 do - sample = sample + math.abs(synth.audioData:getSample(currentSample + s)) - end - sample = sample / 20 + --- + + if music:is_playing() then + local value = music:get_current_sample() + visualizer = visualizer .. string.char(math.floor(math.abs(value * 100))) + if visualizer:len() > 80 then + visualizer = visualizer:sub(2, 81) end - love.graphics.setColor(0.8, 0.8, 0.8, 0.15) - love.graphics.rectangle( - "fill", - i, - love.graphics.getHeight() - love.graphics.getHeight() / 3, - 2, - -sample * love.graphics.getHeight() / 2 - ) - love.graphics.setColor(0.8, 0.8, 0.8, 0.05) - love.graphics.rectangle( - "fill", - i, - love.graphics.getHeight() - love.graphics.getHeight() / 3 + 2, - 2, - (sample * love.graphics.getHeight() / 3) + 2 - ) end end -totalSamples = 0 -currentSample = 0 -timeElapsed = 0 - -Mode = { - playing = {}, - waiting = {}, - loading = {}, - finished = {} -} -function Mode.set(mode) - local mode = mode or "loading" - u, d = Mode[mode].update, Mode[mode].draw -end - -function Mode.waiting.update(dt) -end - -function Mode.waiting.draw() - love.graphics.setColor(0.8, 0.8, 0.8) +function love.draw() + love.graphics.clear(colors:get_color("background")) - love.graphics.print( - "\ndrag-n-drop mml file to open..." .. "\n\npress [ESC] to quit", - love.graphics.getWidth() / 4, - love.graphics.getHeight() / 2 - 12 - ) - drawLines() -end + if music:is_playing() then + for i = 0, visualizer:len() - 1 do + local height = visualizer:sub(i, i):byte() + local width = love.graphics.getWidth() / visualizer:len() + if height == nil then height = 0 end + if i == math.floor(visualizer:len() / 2) then + love.graphics.setColor(colors:get_color("cursor")) + elseif i == math.floor(visualizer:len() / 2) - 1 or i == math.floor(visualizer:len() / 2) + 1 then + love.graphics.setColor(colors:get_color("text_info")) + else + love.graphics.setColor(1, 1, 1, 0.1) + end + love.graphics.rectangle("fill", i * width, 250 - height, 8, height * 2) + end + end -function Mode.playing.update(dt) - time = love.timer.getTime() - startTime - pauseTime - local minutes = math.floor(time / 60) - local seconds = time - minutes * 60 - if seconds < 10 then - seconds = "0" .. string.format("%.2f", seconds) - else - seconds = string.format("%.2f", seconds) + for _, element in pairs(t_ui) do + love.graphics.setColor(colors:get_color(element.color)) + love.graphics.print(element.text, element.x, element.y) end - if minutes < 10 then minutes = "0" .. minutes end - prettyTime = minutes .. ":" .. seconds - --time = string.format("%.2f", love.timer.getTime() - startTime) - if not synth.isPlaying() then - synth.play() - --Mode.set("finished") + --- + + local current_time = love.timer.getTime() + if NEXT_t <= current_time then + NEXT_t = current_time + return end + love.timer.sleep(NEXT_t - current_time) end -function Mode.playing.draw() - love.graphics.setColor(0.8, 0.8, 0.8) - - love.graphics.print( - "\nTITLE: " .. synth.title .. "\nplaying…\n" .. prettyTime .. "\n\npress [ESC] to quit, [space] for pause, [x] to stop" .. "\n\nSaving on exit: " .. tostring(saveOnExit) .. " (press [s] to toggle)", - love.graphics.getWidth() / 4, - love.graphics.getHeight() / 2 - 12 +function love.load() + font = love.graphics.newFont("Assets/FiraCode-Medium.ttf", 16) + love.graphics.setFont(font) + -- see Assets/FiraCode-LICENSE.txt for more info + -- https://github.com/tonsky/FiraCode + love.window.setMode( + 800, + 400, + { + fullscreen = false, + vsync = 1, + resizable = false, + centered = true + } ) + love.window.setTitle("Music v" .. VERSION) + colors = NamedColorPalette:new() + colors:create(require("Assets.colors")) - drawLines() -end + --- -function Mode.finished.update(dt) - pauseTime = love.timer.getTime() - startTime - time - if synth.isPlaying() then - Mode.set("playing") - end + MIN_dt = 1/60 + NEXT_t = love.timer.getTime() end -function Mode.finished.draw() - love.graphics.setColor(0.8, 0.8, 0.8) - - love.graphics.print( - "paused or stopped/finished.\n" .. prettyTime .. "\n\npress [ESC] to quit, [space] for pause, [x] to stop" .. "\n\nSaving on exit: " .. tostring(saveOnExit) .. " (press [s] to toggle)", - love.graphics.getWidth() / 4, - love.graphics.getHeight() / 2 - 12 - ) +function love.quit() end -function love.update(dt) - if dt < 1/50 then - love.timer.sleep(1/50 - dt) - end +function love.filedropped(file) + if file then + file:open("r") + --file_content = file:read() + local content = {} - if love.keyboard.isDown("escape") then - love.event.quit() - end + for line in file:lines() do + if string.len(line) > 0 then table.insert(content, line) end + end + + file:close() - u(dt) -end + music:init() -function love.keyreleased(key) - if key == "s" then - saveOnExit = not saveOnExit - end - if key == "space" then - synth.pause() - end - if key == "x" then - synth.stop() - end -end + local success = music:parse_mml(content) + if success then + t_ui.title_text.text = music.meta.title + t_ui.composer_text.text = music.meta.composer + t_ui.programmer_text.text = music.meta.programmer + t_ui.copyright_text.text = music.meta.copyright -function love.draw() - love.graphics.clear(0.1, 0.1, 0.1) - - d() + local used_voices = music:get_used_voices() + for voice, used in pairs(used_voices) do + if used then + t_ui["voice_" .. voice .. "_text"].color = "text_value" + else + t_ui["voice_" .. voice .. "_text"].color = "text_empty" + end + end + end + else + local error = love.window.showMessageBox("Error", "Unable to open file", "info", true) + end end -function love.quit() - if saveOnExit then +function love.keypressed(key, scancode, isrepeat) + if key == "escape" then + love.event.quit() + + elseif key == "tab" then + music:pause() + + elseif key == "f1" and music.audio.sound_data then + -- export AIFF local content = "" - local file = aiff.createFile(synth.title .. " by " .. synth.composer) - aiff.writeFile(file, { - soundData = synth.audioData, - title = synth.title, - composer = synth.composer + local file = aiff:createFile(music.meta.title .. " by " .. music.meta.composer) + aiff:writeFile(file, { + soundData = music.audio.sound_data, + title = music.meta.title, + composer = music.meta.composer }) - aiff.closeFile(file) - end -end + aiff:closeFile(file) -local __musicFilePath = "" -function love.load() - Mode.set("waiting") + local path = { + macOS = "~/Library/Application Support/LOVE/Music/", + Windows = "%appdata%\\LOVE\\Music\\", + Linux = "~/.local/share/love/" + } + local os = love.system.getOS() + if os == "OS X" then os = "macOS" end + success = love.window.showMessageBox("AIFF-Export", "Saved to " .. path[os], "info", true) + + elseif key == "return" then + -- enter + else + -- ... + end end - -function love.filedropped(file) - synth.stop() - - synth.load(file) - synth.init() - synth.play() - - Mode.set("playing") - startTime = love.timer.getTime() - totalSamples = synth.audioData:getSampleCount() -end \ No newline at end of file diff --git a/makelove.sh b/makelove.sh deleted file mode 100755 index 0737f7f..0000000 --- a/makelove.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh -printf "Name your LÖVE app: " -read NAME -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -cd $DIR -zip -9 -q -r --exclude=*.sh* $NAME.love . - -# create Windows executable on macOS: -# cat love.exe SuperGame.love > SuperGame.exe \ No newline at end of file diff --git a/modules/aiff.lua b/modules/aiff.lua deleted file mode 100644 index 207b069..0000000 --- a/modules/aiff.lua +++ /dev/null @@ -1,153 +0,0 @@ ---[[ - aiff file writer module for Lua/LÖVE - written by Marc Oliver Orth - ___ - ______ ___ ___ ___ / \ ___ - _/ \_/ \_/ _ \_/ \_-- /- \ - / / / / / / /__/ /__/ __/ / / - /___/__/__/\__/\_/___/ \____/ \____/ - (c) 2021 marc2o \______/ - https://marc2o.github.io - ]] - --- WORK IN PROGRESS -function math.clamp(low, n, high) return math.min(math.max(n, low), high) end - -aiff = { - createFile = function (filename) - --return io.open(filename .. ".aiff", "w") - return love.filesystem.newFile(filename .. ".aiff", "w") - end, - writeFile = function (file, args, ...) - local soundData = args.soundData - local sampleRate = soundData:getSampleRate() - local sampleSize = soundData:getBitDepth() - local numChannels = soundData:getChannelCount() - local numSampleFrames = soundData:getSampleCount() / numChannels - local dataSize = soundData:getDuration() * sampleRate * sampleSize / 8 - - file:write("FORM????AIFF") - - file:write(aiff.getChunk({ - ID = "COMM", - dataSize = 18, - numChannels = numChannels, - numSampleFrames = numSampleFrames, - sampleSize = sampleSize, - sampleRate = sampleRate - })) - file:write(aiff.getChunk({ - ID = "SSND", - dataSize = dataSize - })) - - aiff.writePCM({ - file = file, - soundData = soundData, - sampleSize = sampleSize - }) - - file:write(aiff.getChunk({ - ID = "NAME", - text = args.title - })) - file:write(aiff.getChunk({ - ID = "AUTH", - text = args.composer - })) - - local fileInfo = love.filesystem.getInfo(file:getFilename()) - local fileSize = fileInfo.size - file:seek(4) - --local fileSize = file:seek() - --file:seek("set", 4) - file:write(aiff.numberToBytes(fileSize - 8, 4)) - end, - closeFile = function (file) - file:close() - file = nil - end, - - numberToBytes = function (number, numberOfBytes) - local byteChars = "" - local bytes = {} - if numberOfBytes == 4 then - table.insert(bytes, math.floor((number % 2^32) / 2^24)) - end - if numberOfBytes >= 3 then - table.insert(bytes, math.floor((number % 2^24) / 2^16)) - end - if numberOfBytes >= 2 then - table.insert(bytes, math.floor((number % 2^16) / 2^8)) - end - if numberOfBytes >= 1 then - table.insert(bytes, math.floor((number % 2^8))) - end - for i = 1, #bytes do - byteChars = byteChars .. string.char(bytes[i]) - end - return byteChars - end, - - writePCM = function (args, ...) - local size = 2^args.sampleSize / 2 - for i = 0, args.soundData:getSampleCount() - 1 do - local sample = args.soundData:getSample(i) * size - sample = math.clamp(-size, sample, size - 1) - sample = aiff.numberToBytes(sample, args.sampleSize / 8) - args.file:write(sample) - end - end, - - getChunk = function (args, ... ) - if args.ID == "FORM" then - return - "FORM" .. - aiff.numberToBytes(args.dataSize, 4) .. - "AIFF" - end - if args.ID == "COMM" then - local nfreq = string.char(0x40) - if args.sampleRate == 11025 then - nfreq = nfreq .. string.char(0x0c) - elseif args.sampleRate == 22050 then - nfreq = nfreq .. string.char(0x0d) - else - nfreq = nfreq .. string.char(0x0e) - end - nfreq = nfreq .. string.char(0xac) .. string.char(0x44) - for i = 1, 6 do - nfreq = nfreq .. string.char(0) - end - return - "COMM" .. - aiff.numberToBytes(args.dataSize, 4) .. - aiff.numberToBytes(args.numChannels, 2) .. - aiff.numberToBytes(args.numSampleFrames, 4) .. - aiff.numberToBytes(args.sampleSize, 2) .. - nfreq --.. - --"NONE" .. - --"not compressed" - end - if args.ID == "SSND" then - return - "SSND" .. - aiff.numberToBytes(args.dataSize, 4) .. - aiff.numberToBytes(0, 4) .. -- offset - aiff.numberToBytes(0, 4) -- block size - end - if args.ID == "NAME" then - return - "NAME" .. - aiff.numberToBytes(string.len(args.text), 4) .. - args.text - end - if args.ID == "AUTH" then - return - "AUTH" .. - aiff.numberToBytes(string.len(args.text), 4) .. - args.text - end - end -} - diff --git a/modules/synth.lua b/modules/synth.lua deleted file mode 100644 index 7263768..0000000 --- a/modules/synth.lua +++ /dev/null @@ -1,492 +0,0 @@ ---[[ - synthesizer and sequencer module for Lua/LÖVE - written by Marc Oliver Orth - ___ - ______ ___ ___ ___ / \ ___ - _/ \_/ \_/ _ \_/ \_-- /- \ - / / / / / / /__/ /__/ __/ / / - /___/__/__/\__/\_/___/ \____/ \____/ - (c) 2021 marc2o \______/ - https://marc2o.github.io - ]] -synth = { - sampleRate = 11025, --44100 = HQ - bits = 8, - channels = 1, - baseFrequency = 440, - amplitude = 1, - title = "", - composer = "", - programmer = "", - - sequence = { - osc = "TRI", -- default sound - v = 1, - t = 120, - o = 4, - l = 1 - }, - voices = { - currentVoice = "" - }, - - mml = "", - audioData = 0, - music = nil, - - envelopes = { - default = { - attack = 0, - decay = 0, - sustain = 1, - release = 0 - } - }, - - oscillators = { - osc = nil, - --[[ - oscillators based on the denver synthesizer library - https://love2d.org/forums/viewtopic.php?t=79499 - ]] - SIN = function (f) - local phase = 0 - return function() - phase = phase + 2 * math.pi / synth.sampleRate - if phase >= 2 * math.pi then - phase = phase - 2 * math.pi - end - return math.sin(f * phase) - end - end, - - SAW = function (f) - local dv = 2 * f / synth.sampleRate - local v = 0 - return function() - v = v + dv - if v > 1 then v = v - 2 end - return v - end - end, - - SQR = function (f, pwm) - pwm = pwm or 0 - if pwm >= 1 or pwm < 0 then - error('PWM must be between 0 and 1 (0 <= PWM < 1)', 2) - end - local saw = synth.oscillators.SAW(f) - return function() - return saw() < pwm and -1 or 1 - end - end, - - TRI = function (f) - local dv = 1 / synth.sampleRate - local v = 0 - local a = 1 -- up or down - return function() - v = v + a * dv * 4 * f - if v > 1 or v < -1 then - a = a * -1 - v = math.floor(v+.5) - end - return v - end - end, - - NSE = function () - return function() - return math.random() * 2 - 1 - end - end - }, - - reset = function () - synth.voices = { - currentVoice = "" - } - - synth.mml = "" - synth.audioData = 0 - synth.music = nil - - synth.envelopes = { - default = { - attack = 0, - decay = 0, - sustain = 1, - release = 0 - } - } - end, - - load = function (file) - synth.reset() - synth.mml = file:read() - --synth.mml = love.filesystem.read("string", path) - end, - - init = function () - synth.parseMML(synth.mml) - synth.renderAudio() - synth.normalize() - end, - - isPlaying = function () - return synth.music:isPlaying() - end, - - play = function () - synth.music = love.audio.newSource(synth.audioData) - love.audio.play(synth.music) - end, - - stop = function () - love.audio.stop() - end, - - pause = function () - if synth.music:isPlaying() then - love.audio.pause(synth.music) - else - love.audio.play(synth.music) - end - end, - - normalize = function() - local samples = synth.audioData:getSampleCount() - 1 - local peak = 0 - - for i = 0, samples do - local sample = math.abs(synth.audioData:getSample(i)) - if sample > peak then peak = sample end - end - - for i = 0, samples do - local sample = synth.audioData:getSample(i) - local value = (peak - math.abs(sample) - 0.001) / 2 - sample = sample / (peak - value) - synth.audioData:setSample(i, sample) - end - end, - - renderAudio = function () - local key = next(synth.voices) - local maxLength = synth.voices[key].len - local maxNotes = #synth.voices[key].data - local voices = 0 - - for k, v in pairs(synth.voices) do - if string.match(tostring(k), "[ABCDEFGH]") then - if synth.voices[k].len > maxLength then - maxLength = synth.voices[k].len - end - if #synth.voices[key].data > maxNotes then - maxNotes = #synth.voices[key].data - end - voices = voices + 1 - end - end - - synth.voices.totalLength = maxLength - synth.voices.maxNumberOfNotes = maxNotes - synth.voices.number = voices - - synth.audioData = love.sound.newSoundData( - synth.voices.totalLength * synth.sampleRate, - synth.sampleRate, - synth.bits, - synth.channels - ) - - for k, v in pairs(synth.voices) do - if string.match(tostring(k), "[ABCDEFGH]") then - local sampleIndex = 1 - - for i = 1, #synth.voices[k].data do - local lastNote = "" - - if i > 1 then lastNote = synth.voices[k].data[i - 1][1].note end - - local sound = synth.getSound({ - waveform = synth.voices[k].data[i][1].waveform, - note = synth.voices[k].data[i][1].note, - duration = synth.voices[k].data[i][1].duration, - volume = synth.voices[k].data[i][1].volume, - envelope = synth.voices[k].data[i][1].envelope, - lastNote = lastNote - }) - - if sound ~= nil then - for s = 1, sound:getSampleCount() - 1 do - local sample = math.tanh(synth.audioData:getSample(sampleIndex) + sound:getSample(s) / synth.voices.number) - synth.audioData:setSample(sampleIndex, sample) - sampleIndex = sampleIndex + 1 - end - end - end - - end - end - end, - - parseMML = function (mml) - local pos = 1 - local newpos = 0 - local octave = 4 - local volume = nil - local waveform = synth.sequence.osc - local envelope = nil - - for cmd, args, next in string.gmatch(mml, "(#%u+)%s+(.-)\n()") do - if cmd == "#TITLE" then - synth.title = args - elseif cmd == "#COMPOSER" then - synth.composer = args - elseif cmd == "#PROGRAMMER" then - synth.programmer = args - end - end - - for cmd, args, next in string.gmatch(mml, "(@v%d+).-=(.-)\n()") do - local num = string.match(cmd, "(%d+)") - local cmd = string.match(cmd, "(@v)") - local env = string.match(args, "{(.-)}") - local val = {} - for token in string.gmatch(env, "[^%s]+") do - table.insert(val, token / 100) - end - synth.envelopes[num] = {} - synth.envelopes[num].attack = val[1] - synth.envelopes[num].decay = val[2] - synth.envelopes[num].sustain = val[3] - synth.envelopes[num].release = val[4] - pos = next + 1 - end - - repeat - local tie = "" - local cmd, args, newpos = string.match(string.sub(mml, pos), "^([%a<>@&])(%A-)%s-()[%a<>@&]") - - if not cmd then - -- might be the last command in the string. - cmd, args = string.match(string.sub(mml, pos), "^([%a<>@&])(%A-)") - newpos = 0 - end - - if not cmd then - -- might be a comment starting with # and ends with line break - cmd, args, newpos = string.match(string.sub(mml, pos), "^(#)(.-)\n()[%a<>@&]") - end - - if not cmd then - -- probably bad syntax. - error("Malformed MML") - end - - if string.match(cmd, "%u") then -- capital letters indicate channels - synth.voices.currentVoice = cmd - local voiceExists = false - for k, v in pairs(synth.voices) do - if k == cmd then voiceExists = true end - end - if not voiceExists then - volume = nil - synth.voices[synth.voices.currentVoice] = {} - synth.voices[synth.voices.currentVoice].osc = "SQR" - synth.voices[synth.voices.currentVoice].len = 0 - synth.voices[synth.voices.currentVoice].data = {} - end - end - - if cmd == "o" then -- set octave - octave = tonumber(args) - - elseif cmd == "t" then -- set tempo in bpm - synth.sequence.t = tonumber(args) - - elseif cmd == "v" then -- set volume 0 to 100 - if string.sub(mml, pos - 1, pos - 1) ~= "@" then - volume = tonumber(args) / 100 - end - - elseif cmd == "@" then -- set waveform 1 to 5 for current voice - local test = string.match(string.sub(mml, pos), "^(@v%d+)") - if test then - envelope = string.match(test, "(%d+)") - else - local waveforms = { "SIN", "SAW", "SQR", "TRI", "NSE" } - waveform = waveforms[tonumber(args)] - end - - elseif cmd == "&" then -- tie notes - table.insert(synth.voices[synth.voices.currentVoice].data, { - { - waveform = "", - note = "&", - duration = 0, - volume = nil - } - }) - - elseif cmd == "r" or cmd == "p" or cmd == "w" then -- rest, pause (wait is treated as rest for now) - local duration - if args ~= "" then - duration = (4 / tonumber(args)) * (60 / synth.sequence.t) - else - duration = (4 / synth.sequence.l) * (60 / synth.sequence.t) - end - - table.insert(synth.voices[synth.voices.currentVoice].data, { - { - waveform = waveform, - note = "r", - duration = duration, - volume = volume, - envelope = envelope - } - }) - synth.voices[synth.voices.currentVoice].len = synth.voices[synth.voices.currentVoice].len + duration - - elseif cmd == "l" then -- set note length - synth.sequence.l = tonumber(args) - - elseif cmd == ">" then -- increase octave - octave = octave + 1 - - elseif cmd == "<" then -- decrease octave - octave = octave - 1 - - elseif cmd:find("[a-h]") then -- play note using c, d, e, f, g, a, h or b - local note - local mod = string.match(args, "[+#-]") - if mod then - if mod == "#" or mod == "+" then - note = cmd .. "+" .. octave - elseif mod == "-" then - note = cmd .. "-" .. octave - end - else - note = cmd .. octave - end - - local duration - local len = string.match(args, "%d+") - if len then - duration = (4 / tonumber(len)) * (60 / synth.sequence.t) - else - duration = (4 / synth.sequence.l) * (60 / synth.sequence.t) - end - - if string.find(args, "%.") then -- dottet note - duration = duration * 1.5 - end - - table.insert(synth.voices[synth.voices.currentVoice].data, { - { - waveform = waveform, - note = note, - duration = duration, - volume = volume, - envelope = envelope - } - }) - synth.voices[synth.voices.currentVoice].len = synth.voices[synth.voices.currentVoice].len + duration - end - - pos = pos + (newpos - 1) - - until newpos == 0 - end, - - getSound = function (args, ...) - local note = args.note or "a" - local lastNote = args.lastNote or "" - local waveform = args.waveform or synth.sequence.osc - local volumeEnvelope = args.envelope or "default" - local duration = args.duration - local frequency = synth.baseFrequency - local volume = args.volume or synth.sequence.v - - if note == "&" then - return nil - end - - if note ~= "r" then - frequency = synth.noteToFrequency(note) - synth.oscillators.osc = synth.oscillators[waveform](frequency, ...) - end - if note == "r" and lastNote == "r" then - synth.oscillators.osc = nil - end - - local sample = 0 - local data = love.sound.newSoundData(duration * synth.sampleRate, synth.sampleRate, synth.bits, synth.channels) - local envelope = 0 - - local attackSamples = synth.envelopes[volumeEnvelope].attack * synth.sampleRate - local decaySamples = attackSamples + synth.envelopes[volumeEnvelope].decay * synth.sampleRate - local sustainVolume = synth.envelopes[volumeEnvelope].sustain - local releaseSamples = synth.envelopes[volumeEnvelope].release * synth.sampleRate - - for i = 0, duration * synth.sampleRate - 1 do - - if note ~= "r" then - if i <= attackSamples then - envelope = i / (attackSamples - 1) - elseif i > attackSamples and i <= decaySamples then - envelope = sustainVolume + (1 - sustainVolume) * (1 - (i / decaySamples)) - elseif i > decaySamples then - envelope = sustainVolume - end - else - if i <= releaseSamples then - envelope = sustainVolume * (1 - i / releaseSamples) - end - end - - if lastNote == "&" then - envelope = sustainVolume - end - - if synth.oscillators.osc ~= nil then - sample = synth.oscillators.osc(synth.baseFrequency, synth.sampleRate) * synth.amplitude * volume * envelope - else - sample = 0 - end - - data:setSample(i, sample) - end - - return data - end, - - noteToFrequency = function (note) - if not note or type(note) ~= "string" then - return - end - local notes = { c = -9, d = -7, e = -5, f = -4, g = -2, a = 0, b = 2, h = 2 } - local octave = synth.sequence.o - local value = notes[string.sub(note, 1, 1)] - - if string.len(note) == 3 and string.match(string.sub(note, 3, 3), "%d") then - octave = string.sub(note, 3, 3) - end - if string.len(note) >= 2 then - if string.match(string.sub(note, 2, 2), "%d") then - octave = string.sub(note, 2, 2) - end - if string.sub(note, 2, 2) == "+" then - value = value + 1 - end - if string.sub(note, 2, 2) == "-" then - value = value - 1 - end - end - - value = value + 12 * (octave - 4) - return synth.baseFrequency * math.pow(math.pow(2, 1 / 12), value) - end - } - \ No newline at end of file diff --git a/music-preview.png b/music-preview.png new file mode 100644 index 0000000..0237160 Binary files /dev/null and b/music-preview.png differ diff --git a/readme.md b/readme.md index af86ce4..6a5e3c6 100644 --- a/readme.md +++ b/readme.md @@ -1,88 +1,144 @@ -# Music +![Music](music-preview.png) -![Screenshot](https://raw.githubusercontent.com/marc2o/Music/main/assets/screenshot.png) +# Read me -An example of creating sounds and music with [LÖVE](https://love2d.org/), a »framework you can use to make 2D games in Lua.« +This project, aptly named »Music«, is a synthesizer and MML parser written from scratch in Lua/[LÖVE](https://love2d.org/). It is a complete rewrite of my first attempt and this time around I have completely written the synthesizer and parser code myself (and have learned a lot in doing so). -Music is created using MML, a simple [Music Macro Language](https://en.wikipedia.org/wiki/Music_Macro_Language). The demo included is based on the »Music« AmigaBASIC demo program from 1985 ([take a look](https://www.youtube.com/watch?v=522uWGQV134)). +The synthesizer capabilities are loosely based on the NES. -The oscillator code is based on the [Denver Synthesizer Library](https://love2d.org/forums/viewtopic.php?t=79499) and the MML parser is a changed and extended version of the one used in [love-mml](https://github.com/GoonHouse/love-mml). +## Usage -Songs can be saved as AIFF. I have written the save routine from scratch. Just press *s* during playback to toggle saving on exit. The composer and title info, if specified in the mml, will be written to the AIFF as well. +Open any an MML file (either `.txt` or `.mml`) per drag-n-drop on the LÖVE app icon or the app window while Music is running. -The MML instrucions set is not complete, yet – and not really standard in some aspects. +Use `[Tab]` to play or pause the music. -Open mml files per drag-n-drop on the LÖVE app icon or window while _Music_ is running (as long as your mml file is compatible with this programm, of course 😊). The music file is saved at LÖVE’s default location (see [love.filesystem](https://love2d.org/wiki/love.filesystem)). +Use `[F1]` to export as AIFF and `[F2]` for MIDI (Format 1) export. -## Already implemented +AIFF and MIDI files are saved at LÖVE’s default location, which is -**Entering notes: ``** +- `~/Library/Application Support/LOVE/` on macOS +- `%appdata%\LOVE\` on Windows +- `~/.local/share/love/` on Linux -**``** (c, d, e, f, g, a, h or b) +(see [love.filesystem](https://love2d.org/wiki/love.filesystem)). -**``** (+ or -) -**``** (1 … n) ex. 4 is the length of a 1/4 note +## Basic Song Setup -If no length is given, the length specified with the length command **`l`** is used. +### Header Credits -**Rests or pauses: `r`, `p`** +Meta data is defined using keywords. -Either p or r can be used, depending on the MML dialect. +- `#TITLE`: song title +- `#COMPOSER`: the original composer of the song +- `#PROGRAMMER`: the person who coded the song into MML +- `#COPYRIGHT`: well, … +- `#TIMEBASE`: default value is `480` -If no length is given, the length specified with the length command **`l`** is used. -Waits **`w`** are treated as rests for now. +### Channels or Voices -**Tempo: `t`** +There are 5 channels or voices to work with: -**Default note length: `l`** +- `A` is the first pulse wave channel +- `B` is the second pulse wave channel +- `C` is the triangle wave channel +- `D` is the sawtooth wave channel +- `E` is the white noise channel -**Octave: `o`** -Default octave is 4. +### Tempo Settings -**Octave up: `>`** +The tempo is set in beats per minute using the `t` command, e.g.: -**Octave down: `<`** + t120 -**Set volume: `v`** +This command is used before the channels and music data as it sets the tempo for all 5 channels. -**``** (value between 0 and 100) -**``** (A to Z) +### Volume Settings -A capital letter at the beginning of a new line assigns all following commands to a certain voice. The following example plays the notes at the same time on two voices: +The volume is set using the `v` command. The command can be used anywhere in the music, at the beginning of a channel and between notes. -```mml -A cdefg -B cdefg -``` +The pulse wave channels `A` and `B` as well as the sawtooth channel `D` and the noise channel `E` accept values between `0 .. 127`. For the triangle channel `C` the volume can only be turned on `1` or off `0`. -**Set waveform: `@`** + A v80 cdefg -**``** (1 = SIN, 2 = SAW, 3 = SQR, 4 = TRI, 5 = noise) +Volume envelopes can be created using `@env` macros by defining attack (time), decay (time), sustain (volume) and release (time). -**Note tie: `&`** + @env1 = { 1, 32, 64, 80 } -Ties two of the same of different notes together, e. g. two quarter notes tied two one half note`c4&c4` or two different notes tied together `c&d`. -**Volume envelope: `@v = { }`** +### Quantization -**``** (1 to 100) +Since the triangle channel does not support volume controls and envelope macros, quantization can be used to make notes sound snappier. The quantization is set using the `q<1..8>` command. The notes are divided into 8 equal parts. The value following the `q` command defines how many parts are played before the note is cut off. -**``** (0 to 100) The time taken for initial run-up of level from nil to peak, beginning when the key is pressed. -**``** (0 to 100) The time taken for the subsequent run down from the attack level to the designated sustain level. +### Duty Cycle -**``** (0 to 100) The level during the main sequence of the sound's duration, until the key is released. +For the two pulse wave channels `A` and `B` the duty cycle can be set using the duty cycle macro `@<00..03>`. The duty cycle is the ratio of the pulse width to the pulse period. The values correspond to: -**``** (0 to 100) The time taken for the level to decay from the sustain level to zero after the key is released. +- `00` 12.5 % thin raspy sound +- `01` 25 % thick fat sound +- `02` 50 % smooth clear sound +- `03` 75 % same as 25 % but phase-inverted -Call `@v` to use the volume envelope +### Programming Notes -## To do… +For the pitches of notes the usual letters are used. For `h` also `b` can be used. -* a way of defining LFO macros -* and maybe trying to implement some more of the stuff from [PPMCK MML](https://shauninman.com/assets/downloads/ppmck_guide.html) + A c d e f g4 a16 h16 >c c d e f g4 a16 h16 >c<< + +Sharp notes and flat notes are accomplished by adding either `#` (or `+`) and `-` respectively after the note, e. g. `f#`. + +The length of a note is either added after the note or set as a default value for phrases or the whole channel using the `l` command. The length values represent standard note lengths such as `4` for a quarter note and `16` for a sixteenth. + +Dotting a note increases the duration of that note by half of its value. + + A l8 c d e. f2 g4 r + +Pauses (or rests) are set using the `p` or `r`command. A wait `w` is a rest without silencing the previous note. + +Notes can be tied to each other using the `&` symbol. + + +### Octaves + +The default octave can be set for a channel and changed any time between the notes using the `o` and `<` respectively. + + +### Loops (not yet implemented) + +A phrase can be looped n times using square brackets `[` `]`. + + A [c d e c]2 + + +### Additionally + +Comments can be used to make annotations or write lyrics. A comment is everything between the `;` and the end of a line. + + A c d e f g a h >c + ; do re mi fa sol la ti do + + +## To Do + +The following features are not yet implemented: + +- Ties (`&`) +- Loops (`[..]n`) +- Arpeggio macro (`@arp`) +- Vibrato (pitch modulation `@vib`) +- MIDI export + + +## References + +- [MCK/MML Beginners Guide by nullsleep version 1.0](https://archive.nesdev.org/mck_guide_v1.0.txt) +- [Music Macro Language (Electronic Music Wiki)](https://electronicmusic.fandom.com/wiki/Music_Macro_Language) +- [Standard MIDI-File Format Spec. 1.1, updated](http://www.music.mcgill.ca/~ich/classes/mumt306/StandardMIDIfileformat.html) +- [Audio Interchange File Format: AIFF (PDF, McGill University)](https://www-mmsp.ece.mcgill.ca/Documents/AudioFormats/AIFF/Docs/AIFF-1.3.pdf)