Skip to content

Commit

Permalink
lichen-community-systemsGH-5: Fixed timing for multi-track SMF files.
Browse files Browse the repository at this point in the history
  • Loading branch information
duhrer committed Feb 15, 2023
1 parent 51d9a20 commit ccae0d9
Showing 1 changed file with 90 additions and 81 deletions.
171 changes: 90 additions & 81 deletions demos/js/smfPlayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@
},

members: {
currentSeconds: 0,
smfEvents: []
currentSeconds: 0
},

model: {
Expand Down Expand Up @@ -207,12 +206,97 @@

youme.demos.smf.player.handleRunningStateChange = function (that) {
if (that.model.isRunning) {
if (that.smfEvents.length) {
if (fluid.get(that, "model.midiObject.tracks.length")) {
// This should no longer be necessary, as we have recreated the scheduler.
// that.scheduler.clearAll();

that.currentSeconds = 0;
// There are three file types we need to support:
//
// 0: single track with meta events generally at timestamp 0.
// 1: multiple parallel tracks, meta events (including timing) in the first track.
// 2: multiple parallel tracks with independent timing per track.

// When working with type 1 files, we need to hold onto meta events such as tempo for all tracks.
var commonMetaEvents = [];

// We want to stop after the last tick required for any track.
var maxTrackTime = 0;
fluid.each(that.model.midiObject.tracks, function (singleTrack, trackNumber) {
if (that.model.midiObject.header.format === 1 && trackNumber === 0) {
// TODO: Pull out the FPS and initial tempo for timekeeping purposes.
commonMetaEvents = singleTrack.events;
}
else {
var eventsToProcess = commonMetaEvents.concat(singleTrack.events);
eventsToProcess.sort(youme.demos.smf.player.sortByTicksElapsed);

// 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;
var secondsPerTick = that.model.secondsPerTick;

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(eventsToProcess, function (singleEvent) {
// Process meta events first, as tempo changes are forward-facing, i.e. from this point on.
// Empirical secondsPerTick for "hushd": 0.002
// Empirical secondsPerTick for "my master": 0.0006
// Empirical secondsPerTick for "who are you": 0.00075
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?
}

// Time should always move forward for messages, but should only move forward for
// meta-events if we are working with format 0 or 2.
if (singleEvent.tickDelta && (singleEvent.message || that.model.midiObject.header.format !== 1)) {
// Calculate the time delta from the "tick delta".
startTime += singleEvent.tickDelta * secondsPerTick;
maxTrackTime = Math.max(startTime, maxTrackTime);
}

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.applier.change("ticksElapsed", singleEvent.ticksElapsed);
that.events.sendMessage.fire(singleChannelMessage);
}
});
}
});
}
});

// Run the onscreen clock independently.
that.currentSeconds = 0;
var secondsPerFrame = (1 / that.model.fps);
that.scheduler.schedule({
type: "repeat",
Expand Down Expand Up @@ -244,69 +328,10 @@
}
});

// 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;
var secondsPerTick = that.model.secondsPerTick;

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.
// Empirical secondsPerTick for "hushd": 0.002
// Empirical secondsPerTick for "my master": 0.0006
// Empirical secondsPerTick for "who are you": 0.00075
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.applier.change("ticksElapsed", singleEvent.ticksElapsed);
that.events.sendMessage.fire(singleChannelMessage);
}
});
}
});

// Stop playing at the end of the song.
// Stop playing at the end of the longest track.
that.scheduler.schedule({
type: "once",
time: startTime,
time: maxTrackTime,
callback: function () {
that.applier.change("isRunning", false);
}
Expand Down Expand Up @@ -369,22 +394,6 @@
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, trackIndex) {
fluid.each(singleTrack.events, function (event) {
if (event.message || (event.metaEvent && event.metaEvent.type !== "endOfTrack")) {
that.smfEvents.push(fluid.extend({}, event, { track: trackIndex }));
}
});
});

// Sort by "ticks elapsed", so that all events across tracks are in order, earliest to latest.
that.smfEvents.sort(youme.demos.smf.player.sortByTicksElapsed);
});
}
};
Expand Down

0 comments on commit ccae0d9

Please sign in to comment.