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 }