1 /++
2 	An add-on to [arsd.simpleaudio] that provides a MidiOutputThread
3 	that can play files from [arsd.midi].
4 
5 	History:
6 		Added January 1, 2022 (dub v10.5). Not fully stablized but this
7 		release does almost everything I want right now, so I don't
8 		expect to change it much.
9 +/
10 module arsd.midiplayer;
11 
12 // FIXME:  I want a record stream somewhere too, perhaps to a MidiFile and then a callback so you can inject events here as well.
13 
14 import arsd.simpleaudio;
15 import arsd.midi;
16 
17 /++
18 	[arsd.simpleaudio] provides a MidiEvent enum, but the one we're more
19 	interested here is the midi file format event, which is found in [arsd.midi].
20 
21 	This alias disambiguates that we're interested in the file format one, not the enum.
22 +/
23 alias MidiEvent = arsd.midi.MidiEvent;
24 
25 import core.thread;
26 import core.atomic;
27 
28 /++
29 	This is the main feature of this module - a struct representing an automatic midi thread.
30 
31 	It wraps [MidiOutputThreadImplementation] for convenient refcounting and raii messaging.
32 
33 	You create this, optionally set your callbacks to filter/process events as they happen
34 	and deal with end of stream, then pass it a stream and events. The methods will give you
35 	control while the thread manages the timing and dispatching of events.
36 +/
37 struct MidiOutputThread {
38 	@disable this();
39 
40 	static if(__VERSION__ < 2098)
41 		mixin(q{ @disable new(size_t); }); // gdc9 requires the arg fyi, but i mix it in because dmd deprecates before semantic so it can't be versioned out ugh
42 	else
43 		@disable new(); // but new dmd is strict about not allowing it
44 
45 	/// it refcounts the impl.
46 	this(this) {
47 		if(impl)
48 			atomicOp!"+="(impl.refcount, 1);
49 	}
50 	/// when the refcount reaches zero, it exits the impl thread and waits for it to join.
51 	~this() {
52 		if(impl)
53 		if(atomicOp!"-="(impl.refcount, 1) == 0) {
54 			impl.exit();
55 			(cast() impl).join();
56 		}
57 		impl = null;
58 	}
59 
60 	/++
61 		Creates a midi output thread using the given device, and starts it.
62 	+/
63 	this(string device, bool startSuspended = false) {
64 		auto thing = new MidiOutputThreadImplementation(device, startSuspended);
65 		impl = cast(shared) thing;
66 		thing.isDaemon = true;
67 		thing.start();
68 
69 		// FIXME: check if successfully initialized
70 	}
71 
72 	// FIXME: prolly opDispatch wrap it instead
73 	auto getImpl() { return impl; }
74 	/++
75 		You can call any `shared` member of [MidiOutputThreadImplementation] through this
76 		struct.
77 	+/
78 	alias getImpl this;
79 
80 	private shared(MidiOutputThreadImplementation) impl;
81 }
82 
83 class MidiOutputThreadImplementation : Thread {
84 	private int refcount = 1;
85 
86 	/++
87 		Set this if you want to filter or otherwise respond to events.
88 
89 		Return true to continue processing the event, return false if you want
90 		to skip it.
91 
92 		The midi thread calls this function, so beware of cross-thread issues
93 		and make sure you spend as little time as possible in the callback to
94 		avoid throwing off time.
95 	+/
96 	void setCallback(bool delegate(const PlayStreamEvent) callback) shared {
97 		auto us = cast() this;
98 		synchronized(this)
99 			us.callback = callback;
100 	}
101 
102 	/++
103 		Set this to customize what happens when a stream finishes.
104 
105 		You can call [suspend], [loadStream], or [exit] to override
106 		the default behavior of looping the song. Or, return without
107 		calling anything to let it automatically start back over.
108 	+/
109 	void setStreamFinishedCallback(void delegate() callback) shared {
110 		auto us = cast() this;
111 		synchronized(this)
112 			us.streamFinishedCallback = callback;
113 	}
114 
115 	/++
116 		Injects a midi event to the stream. It will be triggered as
117 		soon as possible and will NOT trigger you callback.
118 	+/
119 	void injectEvent(ubyte a, ubyte b, ubyte c) shared {
120 		auto us = cast() this;
121 		uint injected = a | (b << 8) | (c << 16);
122 
123 		synchronized(this) {
124 			us.injectedEvents[(us.injectedEnd++) & 0x0f] = injected;
125 		}
126 
127 		us.event.set();
128 	}
129 
130 	/// ditto
131 	void injectEvent(MidiEvent event) shared {
132 		injectEvent(event.status, event.data1, event.data2);
133 	}
134 
135 	/++
136 		Stops playback and closes the midi device, but keeps the thread waiting
137 		for an [unsuspend] call.
138 
139 		When you do unsuspend, any stream will be restarted from the beginning.
140 	+/
141 	void suspend() shared {
142 		auto us = cast() this;
143 		us.suspended = true;
144 		us.event.set();
145 	}
146 
147 	/// ditto
148 	void unsuspend() shared {
149 		auto us = cast() this;
150 		synchronized(this) {
151 			if(!this.filePending) {
152 				pendingStream = stream;
153 				filePending = true;
154 			}
155 		}
156 		us.suspended = false;
157 		us.event.set();
158 	}
159 
160 	/++
161 		Pauses the midi playback. Will send a silence notes controller message to all channels, but otherwise leaves everything in place for a future call to [unpause].
162 	+/
163 	void pause() shared {
164 		auto us = cast() this;
165 		us.paused = true;
166 		us.event.set();
167 	}
168 
169 	/// ditto
170 	void unpause() shared {
171 		auto us = cast() this;
172 		us.paused = false;
173 		us.event.set();
174 	}
175 
176 	/// ditto
177 	void togglePause() shared {
178 		if(paused)
179 			unpause();
180 		else
181 			pause();
182 	}
183 
184 	/++
185 		Stops the current playback stream. Will call the callback you set in [setCallback].
186 
187 		Note: if you didn't set a callback, `stop` will end the stream, but then it will
188 		automatically loop back to the beginning!
189 	+/
190 	void stop() shared {
191 		auto us = cast() this;
192 		us.stopRequested = true;
193 		us.event.set();
194 	}
195 
196 	/++
197 		Exits the thread. The object is not usable again after calling this.
198 	+/
199 	void exit() shared {
200 		auto us = cast() this;
201 		us.exiting = true;
202 		us.event.set();
203 	}
204 
205 	/++
206 		Changes the speed of the playback clock to the given multiplier. So
207 		passing `2.0` will play at double real time. Calling it again will still
208 		play a double real time; the multiplier is always relative to real time
209 		and will not stack.
210 	+/
211 	void setSpeed(float multiplier) shared {
212 		auto us = cast() this;
213 		auto s = cast(int) (1000 * multiplier);
214 		if(s <= 0)
215 			s = 1;
216 		synchronized(this) {
217 			us.speed = s;
218 		}
219 		us.event.set();
220 	}
221 
222 	/++
223 		If you want to use only injected events as a play stream,
224 		you might use arsd.midi.longWait here and just inject
225 		things as they come.
226 	+/
227 	void loadStream(const(PlayStreamEvent)[] pendingStream) shared {
228 		auto us = cast() this;
229 		synchronized(this) {
230 			us.pendingStream = pendingStream;
231 			us.filePending = true;
232 		}
233 		us.event.set();
234 	}
235 
236 	/++
237 		Instructs the player to start playing - unsuspend if suspended,
238 		unpause if paused. If it is already playing, it will do nothing.
239 	+/
240 	void play() shared {
241 		auto us = cast() this;
242 		if(us.paused)
243 			unpause();
244 		if(us.suspended)
245 			unsuspend();
246 		us.event.set();
247 	}
248 
249 	import core.sync.event;
250 
251 	private Event event;
252 	private bool delegate(const PlayStreamEvent) callback;
253 	private void delegate() streamFinishedCallback;
254 	private bool paused;
255 
256 	private uint[16] injectedEvents;
257 	private int injectedStart;
258 	private int injectedEnd;
259 
260 	private string device;
261 	private bool filePending;
262 	private const(PlayStreamEvent)[] stream;
263 	private const(PlayStreamEvent)[] pendingStream;
264 	private const(PlayStreamEvent)[] loopStream;
265 	private bool suspended;
266 	private int speed = 1000;
267 	private bool exiting;
268 	private bool stopRequested;
269 
270 	/+
271 		Do not modify the stream from outside!
272 	+/
273 
274 	/++
275 		If you use the device string "DUMMY", it will still give you
276 		a timed thread with callbacks, but will not actually write to
277 		any midi device. You might use this if you want, for example,
278 		to display notes visually but not play them so a student can
279 		follow along with the computer.
280 	+/
281 	this(string device = "default", bool startSuspended = false) {
282 		this.device = device;
283 		super(&run);
284 		event.initialize(false, false);
285 		if(startSuspended)
286 			suspended = true;
287 	}
288 
289 	private void run() {
290 
291 		version(linux) {
292 			// this thread has no business intercepting signals from the main thread,
293 			// so gonna block a couple of them
294 			import core.sys.posix.signal;
295 			sigset_t sigset;
296 			auto err = sigemptyset(&sigset);
297 			assert(!err);
298 
299 			err = sigaddset(&sigset, SIGINT); assert(!err);
300 			err = sigaddset(&sigset, SIGCHLD); assert(!err);
301 
302 			err = sigprocmask(SIG_BLOCK, &sigset, null);
303 			assert(!err);
304 		}
305 
306 		typeof(this.streamFinishedCallback) streamFinishedCallback;
307 
308 		suspend:
309 
310 		if(exiting)
311 			return;
312 
313 		while(suspended) {
314 			event.wait();
315 			if(exiting)
316 				return;
317 		}
318 
319 		MidiOutput midiOut = MidiOutput(device);
320 		bool justConstructed = true;
321 		scope(exit) {
322 			// the midi pages say not to send reset upon power up
323 			// so im trying not to send it too much. idk if it actually
324 			// matters tho.
325 			if(!justConstructed)
326 				midiOut.reset();
327 		}
328 
329 		typeof(this.callback) callback;
330 
331 		while(!filePending) {
332 			event.wait();
333 			if(exiting)
334 				return;
335 			if(suspended)
336 				goto suspend;
337 		}
338 
339 		newFile:
340 
341 		if(exiting)
342 			return;
343 
344 		synchronized(this) {
345 			stream = pendingStream;
346 			filePending = false;
347 			pendingStream = null;
348 		}
349 
350 		restart_song:
351 
352 		if(exiting)
353 			return;
354 
355 		if(!justConstructed) {
356 			midiOut.reset();
357 		}
358 		justConstructed = false;
359 
360 		MMClock mmclock;
361 		Duration position;
362 
363 		loopStream = stream;//.save();
364 		mmclock.restart();
365 
366 		foreach(item; stream) {
367 			if(exiting)
368 				return;
369 
370 			while(paused) {
371 				pause:
372 				midiOut.silenceAllNotes();
373 				mmclock.pause();
374 				event.wait();
375 				if(exiting)
376 					return;
377 				if(stopRequested)
378 					break;
379 				if(suspended)
380 					goto suspend;
381 				if(filePending)
382 					goto newFile;
383 			}
384 
385 			mmclock.unpause();
386 
387 			synchronized(this) {
388 				mmclock.speed = this.speed;
389 
390 				callback = this.callback;
391 				playInjectedEvents(&midiOut);
392 			}
393 
394 			position += item.wait;
395 
396 			another_event:
397 			// FIXME: seeking
398 			// FIXME: push and pop song...
399 			// FIXME: note duration down to 64th notes would be like 30 ms at 120 bpm time....
400 			auto diff = mmclock.timeUntil(position);
401 			if(diff > 0.msecs) {
402 				if(!event.wait(diff)) {
403 					if(exiting)
404 						return;
405 					if(stopRequested)
406 						break;
407 					if(suspended)
408 						goto suspend;
409 					if(filePending)
410 						goto newFile;
411 					if(paused)
412 						goto pause;
413 					goto another_event;
414 				}
415 			}
416 
417 			if(callback is null || callback(item)) {
418 				if(item.event.isMeta)
419 					continue;
420 
421 				midiOut.writeMidiMessage(item.event.status, item.event.data1, item.event.data2);
422 			}
423 		}
424 
425 		stopRequested = false;
426 		stream = loopStream;
427 		if(stream.length == 0) {
428 			// there's nothing to loop... exiting or suspending is the only real choice
429 			// this really should never happen but the idea is to avoid being stuck in
430 			// a busy loop.
431 			suspended = true;
432 		}
433 
434 		synchronized(this)
435 			streamFinishedCallback = this.streamFinishedCallback;
436 
437 		if(streamFinishedCallback) {
438 			streamFinishedCallback();
439 		} else {
440 			// default behavior?
441 			// maybe prepare loop and suspend...
442 			if(!filePending) {
443 				suspended = true;
444 			}
445 		}
446 
447 		finalLoop:
448 		if(exiting)
449 			return;
450 		if(suspended)
451 			goto suspend;
452 		if(filePending)
453 			goto newFile;
454 		goto restart_song;
455 	}
456 
457 	// Assumes this holds the `this` synchronized lock!!!
458 	private void playInjectedEvents(MidiOutput* midiOut) {
459 		while((injectedStart & 0x0f) != (injectedEnd & 0x0f)) {
460 			auto a = injectedEvents[injectedStart & 0x0f];
461 			injectedStart++;
462 			midiOut.writeMidiMessage(a & 0xff, (a >> 8) & 0xff, (a >> 16) & 0xff);
463 		}
464 	}
465 }
466 
467 version(midiplayer_demo)
468 void main(string[] args) {
469 	import std.stdio;
470 	import std.file;
471 	auto f = new MidiFile;
472 	f.loadFromBytes(cast(ubyte[]) read(args[1]));
473 
474 	auto t = MidiOutputThread("hw:4");
475 	t.setCallback(delegate(const PlayStreamEvent item) {
476 
477 		if(item.event.channel == 0 && item.midiTicksToNextNoteOnChannel)
478 			writeln(item.midiTicksToNextNoteOnChannel * 64 / f.timing);
479 		return item.event.channel == 0;
480 	});
481 
482 	t.loadStream(f.playbackStream);
483 
484 	readln();
485 
486 	/+
487 	t.loadStream(longWait);
488 
489 	string s = readln();
490 	while(s.length) {
491 		t.injectEvent(MidiEvent.NoteOn(0, s[0], 127));
492 		s = readln()[0 .. $-1];
493 	}
494 
495 	return;
496 	+/
497 
498 
499 	/+
500 	t.loadStream(f.playbackStream);
501 
502 	//f = new MidiFile;
503 	//f.loadFromBytes(cast(ubyte[]) read(args[2]));
504 
505 	//t.loadStream(f.playbackStream);
506 
507 
508 	t.setStreamFinishedCallback(delegate() {
509 		writeln("finished!");
510 		t.pause();
511 		//t.exit();
512 	});
513 
514 	writeln("1");
515 	readln();
516 	writeln("2");
517 	//t.pause();
518 
519 	t.setSpeed(12.0);
520 
521 	while(readln().length) {
522 		t.injectEvent(MIDI_EVENT_NOTE_ON << 4, 55, 0);
523 	}
524 
525 	//t.injectEvent(MIDI_EVENT_PROGRAM_CHANGE << 4, 55, 0);
526 	//t.injectEvent(1 | (MIDI_EVENT_PROGRAM_CHANGE << 4), 55, 0);
527 
528 	writeln("3");
529 	readln();
530 	t.setSpeed(0.5);
531 	writeln("4");
532 	t.unpause();
533 	+/
534 }