From 4b7794ef21315ae300a45a527a2958bcc55fcb4f Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Mon, 13 Feb 2023 15:05:50 +0100 Subject: [PATCH] GH-5: Initial demo for SMF player. --- demos/css/smfPlayer.css | 14 + demos/css/{smf.css => smfViewer.css} | 0 demos/css/{quarterFrame.css => timestamp.css} | 0 demos/js/receiveQuarterFrame.js | 2 +- demos/js/sendQuarterFrame.js | 7 +- demos/js/smfPlayer.js | 362 ++++++++++++++++++ demos/js/{smf.js => smfViewer.js} | 10 +- ...{quarterFrameTimestamp.js => timestamp.js} | 12 +- demos/receive-quarter-frame.html | 4 +- demos/send-quarter-frame.html | 4 +- demos/smf-player.html | 49 +++ demos/{smf.html => smf-viewer.html} | 14 +- 12 files changed, 451 insertions(+), 27 deletions(-) create mode 100644 demos/css/smfPlayer.css rename demos/css/{smf.css => smfViewer.css} (100%) rename demos/css/{quarterFrame.css => timestamp.css} (100%) create mode 100644 demos/js/smfPlayer.js rename demos/js/{smf.js => smfViewer.js} (88%) rename demos/js/{quarterFrameTimestamp.js => timestamp.js} (79%) create mode 100644 demos/smf-player.html rename demos/{smf.html => smf-viewer.html} (58%) diff --git a/demos/css/smfPlayer.css b/demos/css/smfPlayer.css new file mode 100644 index 0000000..088f7eb --- /dev/null +++ b/demos/css/smfPlayer.css @@ -0,0 +1,14 @@ +/* + * Copyright 2023, Tony Atkins + * + * Licensed under the MIT license, see LICENSE for details. + */ + +.main-panel { + display: none; +} + +.start-button { + border-radius: 0.25rem; + padding: 1rem; +} diff --git a/demos/css/smf.css b/demos/css/smfViewer.css similarity index 100% rename from demos/css/smf.css rename to demos/css/smfViewer.css diff --git a/demos/css/quarterFrame.css b/demos/css/timestamp.css similarity index 100% rename from demos/css/quarterFrame.css rename to demos/css/timestamp.css diff --git a/demos/js/receiveQuarterFrame.js b/demos/js/receiveQuarterFrame.js index a79d143..6c8428f 100644 --- a/demos/js/receiveQuarterFrame.js +++ b/demos/js/receiveQuarterFrame.js @@ -25,7 +25,7 @@ components: { timestamp: { - type: "youme.demos.quarterFrame.timestamp", + type: "youme.demos.timestamp", container: "{that}.dom.timestamp", options: { model: { diff --git a/demos/js/sendQuarterFrame.js b/demos/js/sendQuarterFrame.js index f5fafa8..014c449 100644 --- a/demos/js/sendQuarterFrame.js +++ b/demos/js/sendQuarterFrame.js @@ -51,7 +51,7 @@ components: { // TODO: Something to control direction. timestamp: { - type: "youme.demos.quarterFrame.timestamp", + type: "youme.demos.timestamp", container: "{that}.dom.timestamp", options: { model: { @@ -65,10 +65,7 @@ }, outputs: { type: "youme.multiPortSelectorView.outputs", - container: "{that}.dom.outputs", - options: { - desiredPortSpecs: ["Arturia BeatStep"] - } + container: "{that}.dom.outputs" }, scheduler: { type: "berg.scheduler", diff --git a/demos/js/smfPlayer.js b/demos/js/smfPlayer.js new file mode 100644 index 0000000..3a97ae1 --- /dev/null +++ b/demos/js/smfPlayer.js @@ -0,0 +1,362 @@ +/* + * Copyright 2023, Tony Atkins + * + * Licensed under the MIT license, see LICENSE for details. + */ +(function (fluid) { + "use strict"; + var youme = fluid.registerNamespace("youme"); + + // TODO: Add support / controls for looping. Maybe switch to "repeat" events? + fluid.defaults("youme.demos.smf.player", { + gradeNames: ["youme.templateRenderer", "youme.messageSender"], + markup: { + container: "
" + }, + selectors: { + controls: ".playback-controls", + fileInput: ".smf-player-file-input", + mainPanel: ".main-panel", + outputs: ".outputs", + startButton: ".start-button", + timestamp: ".timestamp" + }, + + members: { + currentSeconds: 0, + smfEvents: [] + }, + + model: { + // TODO: Display file metadata onscreen. + fileName: "No file selected", + + // MIDI file information + midiObject: {}, + + // Onscreen timestamp + hour: 0, + minute: 0, + second: 0, + frame: 0, + + fps: 30, + + isRunning: false + }, + + invokers: { + handleStartButtonClick: { + funcName: "youme.demos.smf.player.startScheduler", + args: ["{that}"] + }, + handleFileInputChange: { + funcName: "youme.demos.smf.player.handleFileInputChange", + args: ["{that}", "{that}.dom.fileInput"] // HTMLInputElement + } + }, + + listeners: { + "onCreate.bindStartButton": { + this: "{that}.dom.startButton", + method: "click", + args: ["{that}.handleStartButtonClick"] + }, + "onCreate.bindFileInputChange": { + this: "{that}.dom.fileInput", + method: "change", + args: "{that}.handleFileInputChange" + }, + + "sendMessage.sendToOutputs": "{outputs}.events.sendMessage.fire" + }, + + modelListeners: { + isRunning: { + excludeSource: "init", + funcName: "youme.demos.smf.player.handleRunningStateChange", + args: ["{that}"] + } + }, + + components: { + timestamp: { + type: "youme.demos.timestamp", + container: "{that}.dom.timestamp", + options: { + model: { + timestamp: "{youme.demos.smf.player}.model.timestamp", + hour: "{youme.demos.smf.player}.model.hour", + minute: "{youme.demos.smf.player}.model.minute", + second: "{youme.demos.smf.player}.model.second", + frame: "{youme.demos.smf.player}.model.frame" + } + } + }, + outputs: { + type: "youme.multiPortSelectorView.outputs", + container: "{that}.dom.outputs" + }, + scheduler: { + type: "berg.scheduler", + options: { + components: { + clock: { + type: "berg.clock.raf", + // type: "berg.clock.autoAudioContext", + options: { + // freq: 120 // times per second + freq: 60 + } + } + } + } + }, + controls: { + type: "youme.templateRenderer", + container: "{that}.dom.controls", + options: { + model: { + isRunning: "{youme.demos.smf.player}.model.isRunning" + }, + modelRelay: { + startButtonToggle: { + target: "{that}.model.dom.start.enabled", + singleTransform: { + "type": "fluid.transforms.condition", + "condition": "{that}.model.isRunning", + "true": false, + "false": true + } + }, + stopButtonToggle: { + source: "isRunning", + target: "{that}.model.dom.stop.enabled" + } + }, + markup: { + container: "
" + }, + selectors: { + start: ".start", + stop: ".stop" + }, + invokers: { + handleStartButtonClick: { + func: "{that}.applier.change", + args: ["isRunning", true] + }, + handleStopButtonClick: { + func: "{that}.applier.change", + args: ["isRunning", false] + } + }, + listeners: { + "onCreate.bindStartButton": { + this: "{that}.dom.start", + method: "click", + args: ["{that}.handleStartButtonClick"] + }, + "onCreate.bindStopButton": { + this: "{that}.dom.stop", + method: "click", + args: ["{that}.handleStopButtonClick"] + } + } + } + } + } + }); + + youme.demos.smf.player.startScheduler = function (that) { + var startButtonElement = that.locate("startButton"); + startButtonElement.hide(); + + var mainPanelElement = that.locate("mainPanel"); + mainPanelElement.show(); + + // We start and stop so that the first start can be associated with the user click. + that.scheduler.start(); + that.scheduler.stop(); + }; + + youme.demos.smf.player.handleRunningStateChange = function (that) { + if (that.model.isRunning) { + if (that.smfEvents.length) { + that.scheduler.clearAll(); + + that.currentSeconds = 0; + + var secondsPerFrame = (1 / that.model.fps); + that.scheduler.schedule({ + type: "repeat", + interval: secondsPerFrame, + callback: function () { + that.currentSeconds += secondsPerFrame; + + // Batch the model changes for this timestamp. + var transaction = that.applier.initiate(); + + var timeObject = youme.demos.smf.player.timeObjectFromSeconds(that.currentSeconds); + + // Gate all the other updates conditionally up front. + if (timeObject.second !== that.model.second) { + transaction.fireChangeRequest({ path: "second", value: timeObject.second}); + } + + if (timeObject.minute !== that.model.minute) { + transaction.fireChangeRequest({ path: "minute", value: timeObject.minute}); + } + + if (timeObject.hour !== that.model.hour) { + transaction.fireChangeRequest({ path: "hour", value: timeObject.hour}); + } + + var newFrame = (that.model.frame + 1) % that.model.fps; + transaction.fireChangeRequest({ path: "frame", value: newFrame}); + transaction.commit(); + } + }); + + // Start with what's in the header and update as we go. + var ticksPerQuarterNote = that.model.midiObject.header.division.resolution; + + // Arbitrary default to line up "ticks per quarter note" with the timestamp. + var fps = 30; + var secondsPerQuarterNote = .5; + // The default beat time, 0.5 seconds, divided by the default ticks/beat, 60. + var secondsPerTick = 0.008333333333333; + + if (that.model.midiObject.header.division.type === "ticksPerQuarterNote") { + secondsPerTick = secondsPerQuarterNote / ticksPerQuarterNote; + } + // { type: "framesPerSecond", fps: 30, ticksPerFrame: 80}}, + else if (that.model.midiObject.header.division.type === "framesPerSecond") { + fps = that.model.midiObject.header.division.fps; + secondsPerTick = 1 / fps / that.model.midiObject.header.division.ticksPerFrame; + } + + var startTime = 0; + + fluid.each(that.smfEvents, function (singleEvent) { + if (singleEvent.tickDelta) { + // Calculate the time delta from the "tick delta". + startTime += singleEvent.tickDelta * secondsPerTick; + } + + // Process meta events last, as tempo changes are forward-facing, i.e. from this point on. + if (singleEvent.metaEvent) { + if (singleEvent.metaEvent.type === "tempo") { + // TODO: Confirm that this is appropriate for `framesPerSecond` time division (if we can + // ever find an example file in the wild). + // Another way of putting "microseconds per quarter-note" is "24ths of a microsecond per MIDI clock" + secondsPerQuarterNote = singleEvent.metaEvent.value / 1000000; + secondsPerTick = secondsPerQuarterNote / ticksPerQuarterNote; + } + + // TODO: Add support for displaying text and lyrics? + } + + if (singleEvent.message) { + // TODO: Figure out a saner way to optionally force all notes to one channel. + var singleChannelMessage = fluid.extend({}, singleEvent.message, { channel: 0}); + that.scheduler.schedule({ + type: "once", + time: startTime, + callback: function () { + that.events.sendMessage.fire(singleChannelMessage); + } + }); + } + }); + + // Stop playing at the end of the song. + that.scheduler.schedule({ + type: "once", + time: startTime, + callback: function () { + that.applier.change("isRunning", false); + } + }); + + that.scheduler.start(); + } + else { + that.applier.change("isRunning", false); + } + } + else { + // Clear the current timestamp. + var transaction = that.applier.initiate(); + transaction.fireChangeRequest({ path: "second", value: 0}); + transaction.fireChangeRequest({ path: "minute", value: 0}); + transaction.fireChangeRequest({ path: "hour", value: 0 }); + transaction.fireChangeRequest({ path: "frame", value: 0}); + transaction.commit(); + + that.scheduler.stop(); + } + }; + + youme.demos.smf.player.timeObjectFromSeconds = function (currentSeconds) { + var timeObject = {}; + + timeObject.second = (60 + Math.round(currentSeconds)) % 60; + timeObject.minute = (60 + Math.floor( currentSeconds / 60)) % 60; + timeObject.hour = (24 + Math.floor(currentSeconds / 3600)) % 24; + + return timeObject; + }; + + youme.demos.smf.player.handleFileInputChange = function (that, htmlInputElement) { + // Stop playing. + that.applier.change("isRunning", false); + + var file = fluid.get(htmlInputElement, "0.files.0"); + + // TODO: Display "select a file" message or something when no file is selected. + if (file === undefined) { + that.applier.change("fileName", "No file selected."); + } + else { + that.applier.change("fileName", file.name); + + var promise = file.arrayBuffer(); + promise.then(function (arrayBuffer) { + var intArray = new Uint8Array(arrayBuffer); + var midiObject = youme.smf.parseSMFByteArray(intArray); + + var transaction = that.applier.initiate(); + transaction.fireChangeRequest({ path: "midiObject", type: "DELETE"}); + transaction.fireChangeRequest({ path: "midiObject", value: midiObject}); + transaction.commit(); + + // The amount of "ticks" stays constant, but the time per tick changes as we encounter tempo meta + // events. So we track messages and meta events by tick, and iterate through them, converting to + // time values that reflect any tempo changes we encounter. + that.smfEvents = []; + + fluid.each(midiObject.tracks, function (singleTrack) { + fluid.each(singleTrack.events, function (event) { + if (event.message || (event.metaEvent && event.metaEvent.type !== "endOfTrack")) { + that.smfEvents.push(event); + } + }); + }); + + // Sort by "ticks elapsed", so that all events across tracks are in order, earliest to latest. + that.smfEvents.sort(youme.demos.smf.player.sortByTicksElapsed); + }); + } + }; + + // Sort in numerical order by "elapsed ticks". + youme.demos.smf.player.sortByTicksElapsed = function (a, b) { + if (a.ticksElapsed !== undefined && b.ticksElapsed !== undefined) { + return a.ticksElapsed - b.ticksElapsed; + } + else { + fluid.fail("There should be no events without elapsed ticks."); + } + }; +})(fluid); diff --git a/demos/js/smf.js b/demos/js/smfViewer.js similarity index 88% rename from demos/js/smf.js rename to demos/js/smfViewer.js index 2697871..3f69b29 100644 --- a/demos/js/smf.js +++ b/demos/js/smfViewer.js @@ -7,7 +7,7 @@ "use strict"; var youme = fluid.registerNamespace("youme"); - fluid.defaults("youme.demos.smf", { + fluid.defaults("youme.demos.smf.viewer", { gradeNames: ["youme.templateRenderer"], markup: { @@ -38,14 +38,14 @@ modelListeners: { errorString: { - funcName: "youme.demos.smf.displayErrorString", + funcName: "youme.demos.smf.viewer.displayErrorString", args: ["{that}"] } }, invokers: { handleInputChange: { - funcName: "youme.demos.smf.handleInputChange", + funcName: "youme.demos.smf.viewer.handleInputChange", args: ["{that}", "{that}.dom.input"] // HTMLInputElement } }, @@ -59,14 +59,14 @@ } }); - youme.demos.smf.displayErrorString = function (that) { + youme.demos.smf.viewer.displayErrorString = function (that) { var errorElement = that.locate("error"); if (errorElement) { errorElement.html(that.model.errorString); } }; - youme.demos.smf.handleInputChange = function (that, htmlInputElement) { + youme.demos.smf.viewer.handleInputChange = function (that, htmlInputElement) { that.applier.change("errorString", false); var file = fluid.get(htmlInputElement, "0.files.0"); if (file === undefined) { diff --git a/demos/js/quarterFrameTimestamp.js b/demos/js/timestamp.js similarity index 79% rename from demos/js/quarterFrameTimestamp.js rename to demos/js/timestamp.js index 8a96cc9..ba53647 100644 --- a/demos/js/quarterFrameTimestamp.js +++ b/demos/js/timestamp.js @@ -8,7 +8,7 @@ var youme = fluid.registerNamespace("youme"); - fluid.defaults("youme.demos.quarterFrame.timestamp", { + fluid.defaults("youme.demos.timestamp", { gradeNames: ["youme.templateRenderer"], model: { hour: 0, @@ -29,35 +29,35 @@ hour: { singleTransform: { input: "{that}.model.hour", - type: "youme.demos.quarterFrame.timestamp.padStart" + type: "youme.demos.timestamp.padStart" }, target: "{that}.model.dom.hour.text" }, minute: { singleTransform: { input: "{that}.model.minute", - type: "youme.demos.quarterFrame.timestamp.padStart" + type: "youme.demos.timestamp.padStart" }, target: "{that}.model.dom.minute.text" }, second: { singleTransform: { input: "{that}.model.second", - type: "youme.demos.quarterFrame.timestamp.padStart" + type: "youme.demos.timestamp.padStart" }, target: "{that}.model.dom.second.text" }, frame: { singleTransform: { input: "{that}.model.frame", - type: "youme.demos.quarterFrame.timestamp.padStart" + type: "youme.demos.timestamp.padStart" }, target: "{that}.model.dom.frame.text" } } }); - youme.demos.quarterFrame.timestamp.padStart = function (number) { + youme.demos.timestamp.padStart = function (number) { return (number).toString().padStart(2, "0"); }; })(fluid); diff --git a/demos/receive-quarter-frame.html b/demos/receive-quarter-frame.html index 27f8e8b..5efca71 100644 --- a/demos/receive-quarter-frame.html +++ b/demos/receive-quarter-frame.html @@ -27,11 +27,11 @@ - + - +

MTC Quarter Frame "Receive" Demo

diff --git a/demos/send-quarter-frame.html b/demos/send-quarter-frame.html index 72f182a..f7e2cb1 100644 --- a/demos/send-quarter-frame.html +++ b/demos/send-quarter-frame.html @@ -28,10 +28,10 @@ - + - + diff --git a/demos/smf-player.html b/demos/smf-player.html new file mode 100644 index 0000000..80c16a2 --- /dev/null +++ b/demos/smf-player.html @@ -0,0 +1,49 @@ + + + + + "Standard" MIDI File Player Demo + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

"Standard" MIDI File Player Demo

+ +

Upload a MIDI file and send its messages to an external MIDI output.

+ +
+ + + + diff --git a/demos/smf.html b/demos/smf-viewer.html similarity index 58% rename from demos/smf.html rename to demos/smf-viewer.html index 19a4202..1aff85c 100644 --- a/demos/smf.html +++ b/demos/smf-viewer.html @@ -5,7 +5,7 @@ --> - "Standard" MIDI file demo. + "Standard" MIDI File Viewer @@ -13,18 +13,20 @@ - + - + -

"Standard" MIDI file demo.

+

"Standard" MIDI File Viewer

-
+

Upload a MIDI file to view its contents as a JSON structure.

+ +