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