1ardour { 2 ["type"] = "EditorAction", 3 name = "Scala to MIDI Tuning", 4 license = "MIT", 5 author = "Ardour Team", 6 description = [[Read scala (.scl) tuning from a file, generate MIDI tuning standard (MTS) messages and send them to a MIDI port]] 7} 8 9function factory () return function () 10 11 -- return table of all MIDI tracks, and all instrument plugins. 12 -- 13 -- MidiTrack::write_immediate_event() injects MIDI events to the track's input 14 -- PluginInsert::write_immediate_event() sends events directly to a plugin 15 function midi_targets () 16 local rv = {} 17 for r in Session:get_tracks():iter() do 18 19 if not r:to_track():isnil() then 20 local mtr = r:to_track():to_midi_track() 21 if not mtr:isnil() then 22 rv["Track: '" .. r:name() .. "'"] = mtr 23 end 24 end 25 26 local i = 0; 27 while true do 28 local proc = r:nth_plugin (i) 29 if proc:isnil () then break end 30 local pi = proc:to_plugininsert () 31 if pi:is_instrument () then 32 rv["Track: '" .. r:name() .. "' | Plugin: '" .. pi:name() .. "'"] = pi 33 end 34 i = i + 1 35 end 36 end 37 return rv 38 end 39 40 function log2 (v) 41 return math.log (v) / math.log (2) 42 end 43 44 -- calculate MIDI note-number and cent-offset for a given frequency 45 -- 46 -- "The first byte of the frequency data word specifies the nearest equal-tempered 47 -- semitone below the frequency. The next two bytes (14 bits) specify the fraction 48 -- of 100 cents above the semitone at which the frequency lies." 49 -- 50 -- 68 7F 7F = 439.9984 Hz 51 -- 69 00 00 = 440.0000 Hz 52 -- 69 00 01 = 440.0016 Hz 53 -- 54 -- NB. 7F 7F 7F = no change (reserved) 55 -- 56 function freq_to_mts (hz) 57 local note = math.floor (12. * log2 (hz / 440) + 69.0) 58 local freq = 440.0 * 2.0 ^ ((note - 69) / 12); 59 local cent = 1200.0 * log2 (hz / freq) 60 -- fixup rounding errors 61 if cent >= 99.99 then 62 note = note + 1 63 cent = 0 64 end 65 if cent < 0 then 66 cent = 0 67 end 68 return note, cent 69 end 70 71 local dialog_options = { 72 { type = "file", key = "file", title = "Select .scl file" }, 73 { type = "checkbox", key = "bulk", default = false, title = "Bulk Transfer (not realtime)" }, 74 { type = "dropdown", key = "tx", title = "MIDI SysEx Target", values = midi_targets () } 75 } 76 77 local rv = LuaDialog.Dialog ("Select Scala File and MIDI Taget", dialog_options):run () 78 dialog_options = nil -- drop references (track, plugins, shared ptr) 79 collectgarbage () -- and release the references immediately 80 81 if not rv then return end -- user cancelled 82 83 -- read the scl file 84 local freqtbl = {} 85 local ln = 0 86 local expected_len = 0 87 local f = io.open (rv["file"], "r") 88 89 if not f then 90 LuaDialog.Message ("Scala to MTS", "File Not Found", LuaDialog.MessageType.Error, LuaDialog.ButtonType.Close):run () 91 return 92 end 93 94 -- parse scala file and convert all intervals to cents 95 -- http://www.huygens-fokker.org/scala/scl_format.html 96 freqtbl[1] = 0.0 -- implicit 97 for line in f:lines () do 98 line = string.gsub (line, "%s", "") -- remove all whitespace 99 if line:sub(0,1) == '!' then goto nextline end -- comment 100 ln = ln + 1 101 if ln < 2 then goto nextline end -- name 102 if ln < 3 then 103 expected_len = tonumber (line) -- number of notes on scale 104 if expected_len < 1 or expected_len > 256 then break end -- invalid file 105 goto nextline 106 end 107 108 local cents 109 if string.find (line, ".", 1, true) then 110 cents = tonumber (line) 111 else 112 local n, d = string.match(line, "(%d+)/(%d+)") 113 if n then 114 cents = 1200 * log2 (n / d) 115 else 116 local n = tonumber (line) 117 cents = 1200 * log2 (n) 118 end 119 end 120 --print ("SCL", ln - 2, cents) 121 freqtbl[ln - 1] = cents 122 123 ::nextline:: 124 end 125 f:close () 126 127 -- We need at least one interval. 128 -- While legal in scl, single note scales are not useful here. 129 if expected_len < 1 or expected_len + 2 ~= ln then 130 LuaDialog.Message ("Scala to MTS", "Invalid or unusable scale file.", LuaDialog.MessageType.Error, LuaDialog.ButtonType.Close):run () 131 return 132 end 133 134 assert (expected_len + 2 == ln) 135 assert (expected_len > 0) 136 137 ----------------------------------------------------------------------------- 138 -- TODO consider reading a .kbm file or make these configurable in the dialog 139 -- http://www.huygens-fokker.org/scala/help.htm#mappings 140 local ref_note = 69 -- Reference note for which frequency is given 141 local ref_freq = 440.0 -- Frequency to tune the above note to 142 local ref_root = 60 -- root-note of the scale, note where the first entry of the scale is mapped to 143 local note_start = 0 144 local note_end = 127 145 ----------------------------------------------------------------------------- 146 147 -- prepare sending data 148 local send_bulk = rv['bulk'] 149 local tx = rv["tx"] -- output port 150 local parser = ARDOUR.RawMidiParser () -- construct a MIDI parser 151 local checksum = 0 152 153 if send_bulk then 154 note_start = 0 155 note_end = 127 156 end 157 158 --local dump = io.open ("/tmp/dump.syx", "wb") 159 160 -- helper function to send MIDI 161 function tx_midi (syx, len, hdr) 162 for b = 1, len do 163 --dump:write (string.char(syx:byte (b))) 164 165 -- calculate checksum, xor of all payload data 166 -- (excluding the 0xf0, 0xf7, and the checksum field) 167 if b >= hdr then 168 checksum = checksum ~ syx:byte (b) 169 end 170 171 -- parse message to C/C++ uint8_t* array (Validate message correctness. This 172 -- also returns C/C++ uint8_t* array for direct use with write_immediate_event.) 173 if parser:process_byte (syx:byte (b)) then 174 tx:write_immediate_event (Evoral.EventType.MIDI_EVENT, parser:buffer_size (), parser:midi_buffer ()) 175 -- Slow things down a bit to ensure that no messages as lost. 176 -- Physical MIDI is sent at 31.25kBaud. 177 -- Every message is sent as 10bit message on the wire, 178 -- so every MIDI byte needs 320usec. 179 ARDOUR.LuaAPI.usleep (400 * parser:buffer_size ()) 180 end 181 end 182 end 183 184 -- show progress dialog 185 local pdialog = LuaDialog.ProgressWindow ("Scala to MIDI Tuning", true) 186 pdialog:progress (0, "Tuning"); 187 188 -- calculate frequency at ref_root 189 local delta = ref_note - ref_root 190 local delta_octv = math.floor (delta / expected_len) 191 local delta_note = delta % expected_len 192 193 -- inverse mapping, ref_note will have the specified frequency in the target scale, 194 -- while the scale itself will start at ref_root 195 local ref_base = ref_freq * 2 ^ ((freqtbl[delta_note + 1] + freqtbl[expected_len + 1] * delta_octv) / -1200) 196 197 if send_bulk then 198 -- MIDI Tuning message 199 -- http://technogems.blogspot.com/2018/07/using-midi-tuning-specification-mts.html 200 -- http://www.ludovico.net/download/materiale_didattico/midi/08_midi_tuning.pdf 201 local syx = string.char ( 202 0xf0, 0x7e, -- non-realtime sysex 203 0x00, -- target-id 204 0x08, 0x01, -- tuning, bulk dump reply 205 0x00, -- tuning program number 0 to 127 in hexadecimal 206 -- 16 chars name (zero padded) 207 0x53, 0x63, 0x6C, 0x2D, 0x4D, 0x54, 0x53, 0x00, -- Scl-MTS 208 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 209 ) 210 tx_midi (syx, 22, 1) 211 end 212 213 -- iterate over MIDI notes 214 for nn = note_start, note_end do 215 if pdialog:canceled () then break end 216 217 -- calculate the note relative to kbm's ref_root 218 delta = nn - ref_root 219 delta_octv = math.floor (delta / expected_len) 220 delta_note = delta % expected_len 221 222 -- calculate the frequency of the note according to the scl 223 local fq = ref_base * 2 ^ ((freqtbl[delta_note + 1] + freqtbl[expected_len + 1] * delta_octv) / 1200) 224 225 -- and then convert this frequency to the MIDI note number (and cent offset) 226 local base, cent = freq_to_mts (fq) 227 228 -- MTS uses two MIDI bytes (2^14) for cents 229 local cc = math.floor (163.83 * cent + 0.5) | 0 230 local cent_msb = (cc >> 7) & 127 231 local cent_lsb = cc & 127 232 233 --[[ 234 print (string.format ("MIDI-Note %3d | Octv: %+d Note: %2d -> Freq: %8.2f Hz = note: %3d + %6.3f ct (0x%02x 0x%02x 0x%02x)", 235 nn, delta_octv, delta_note, fq, base, cent, base, cent_msb, cent_lsb)) 236 --]] 237 238 if (base < 0 or base > 127) then 239 if send_bulk then 240 if base < 0 then 241 base = 0 242 else 243 base = 127 244 end 245 cent_msb = 0 246 cent_lsb = 0 247 else 248 -- skip out of bounds MIDI notes 249 goto continue 250 end 251 end 252 253 if send_bulk then 254 local syx = string.char ( 255 base, -- semitone (MIDI note number to retune to, unit is 100 cents) 256 cent_msb, -- MSB of fractional part (1/128 semitone = 100/128 cents = .78125 cent units) 257 cent_lsb, -- LSB of fractional part (1/16384 semitone = 100/16384 cents = .0061 cent units) 258 0xf7 259 ) 260 tx_midi (syx, 3, 0) 261 else 262 checksum = 0x07 -- really unused 263 -- MIDI Tuning message 264 -- http://www.microtonal-synthesis.com/MIDItuning.html 265 local syx = string.char ( 266 0xf0, 0x7f, -- realtime sysex 267 0x7f, -- target-id 268 0x08, 0x02, -- tuning, note change request 269 0x00, -- tuning program number 0 to 127 in hexadecimal 270 0x01, -- number of notes to be changed 271 nn, -- note number to be changed 272 base, -- semitone (MIDI note number to retune to, unit is 100 cents) 273 cent_msb, -- MSB of fractional part (1/128 semitone = 100/128 cents = .78125 cent units) 274 cent_lsb, -- LSB of fractional part (1/16384 semitone = 100/16384 cents = .0061 cent units) 275 0xf7 276 ) 277 tx_midi (syx, 12, 0) 278 end 279 280 -- show progress 281 pdialog:progress (nn / 127, string.format ("Note %d freq: %.2f (%d + %d)", nn, fq, base, cc)) 282 if pdialog:canceled () then break end 283 284 ::continue:: 285 end 286 287 if send_bulk and not pdialog:canceled () then 288 tx_midi (string.char ((checksum & 127), 0xf7), 2, 2) 289 end 290 291 -- hide modal progress dialog and destroy it 292 pdialog:done (); 293 294 tx = nil 295 parser = nil 296 collectgarbage () -- and release any references 297 298 --dump:close () 299 300end end 301 302-- simple icon 303function icon (params) return function (ctx, width, height, fg) 304 ctx:set_source_rgba (ARDOUR.LuaAPI.color_to_rgba (fg)) 305 local txt = Cairo.PangoLayout (ctx, "ArdourMono ".. math.ceil(math.min (width, height) * .45) .. "px") 306 txt:set_text ("SCL\nMTS") 307 local tw, th = txt:get_pixel_size () 308 ctx:move_to (.5 * (width - tw), .5 * (height - th)) 309 txt:show_in_cairo_context (ctx) 310end end 311