Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions shared/midiplay.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export function midiPlayer(host, { onPlay, onStop, onTick } = {}) {

const p = {
ctx: null, synth: null, seq: null, out: null, meter: null, ready: null,
loaded: false, playing: false, midi: null, op: 0, _duration: 0,
loaded: false, playing: false, midi: null, op: 0, _duration: 0, keyShift: 0,
/** make and resume the AudioContext: call inside a click or key press, before any await */
unlock() {
if (!this.ctx) this.ctx = new AudioContext();
Expand Down Expand Up @@ -160,11 +160,13 @@ export function midiPlayer(host, { onPlay, onStop, onTick } = {}) {
this.seq.eventHandler.addEvent("songChange", "midiplay-load", () => ok());
setTimeout(ok, 4000);
});
this.synth.setSystemParameter("keyShift", this.keyShift);
this.seq.loadNewSongList([{ binary: toBuffer(bytes), fileName: "preview.mid" }]);
this.seq.loopCount = 0;
this.seq.pause();
await changed;
this.seq.eventHandler.removeEvent("songChange", "midiplay-load");
this.synth.setSystemParameter("keyShift", this.keyShift); // in case the new song reset the synth
if (my !== this.op) return false;
this._duration = this.seq.duration || 0;
try { this.midi = await this.seq.getMIDI(); } catch { this.midi = null; }
Expand All @@ -176,6 +178,12 @@ export function midiPlayer(host, { onPlay, onStop, onTick } = {}) {
paint();
return true;
},
/** play every note but the drums `k` semitones up or down, at once and without reloading:
* the synth's own key shift, so a transpose never stops the music */
setKeyShift(k) {
this.keyShift = k;
this.synth?.setSystemParameter("keyShift", k);
},
get duration() { return this._duration; },
get time() {
if (!this.seq || !this.loaded) return 0;
Expand Down Expand Up @@ -297,6 +305,8 @@ export function rollView(canvas, { beats: span = 32, scrollbar = null } = {}) {
const R = window.TabridgeRoll;
const ctx = canvas.getContext("2d");
let pal = null, notes = [], range = [48, 72, 48, 72], last = null, end = span;
let base = [], shift = 0; // the song's own notes, and a live transpose on them
const shifted = () => (shift ? base.map((n) => ({ ...n, pitch: n.pitch + shift })) : base);
let from = 0; // the first beat on show
let spacer = null;
if (scrollbar) {
Expand Down Expand Up @@ -361,12 +371,20 @@ export function rollView(canvas, { beats: span = 32, scrollbar = null } = {}) {
const view = {
/** the notes-JSON of the piece ({ tracks: [{ isDrums, notes: [{ pitch, start, dur }] }] }, beats) */
setSong(song) {
notes = (song?.tracks || []).filter((t) => !t.isDrums).flatMap((t) => t.notes);
base = (song?.tracks || []).filter((t) => !t.isDrums).flatMap((t) => t.notes);
notes = shifted();
end = Math.max(4, ...(song?.tracks || []).flatMap((t) => t.notes.map((x) => x.start + x.dur)));
setRange();
from = 0;
view.draw(last);
},
/** every note `k` semitones up or down (a live transpose), the view staying where it is */
setShift(k) {
shift = k;
notes = shifted();
setRange();
paint(last);
},
/** which page of `span` beats is on show */
get page() { return Math.floor(from / span + 1e-6); },
/** beats on show at once; the whole song is `whole` */
Expand Down
31 changes: 17 additions & 14 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,16 @@ site.then(({ proxy }) => {
});

// Transpose: the design system's value box (-12..+12, default 0), like the hero's.
// Changing it while a preview is showing re-renders the preview with the new
// shift; a short wait lets a held step settle first.
let rerender = 0;
// The preview is built once at 0 and transposed live by the synth's key shift and
// the roll's shift, so a change is heard at once and the music never stops.
// Each newly opened piece starts at 0.
const transpose = valueBox($("semitones"), {
min: -12, max: 12, value: 0, labelledBy: "semitones-label",
format: (v) => (v > 0 ? "+" : "") + v,
onChange: () => {
clearTimeout(rerender);
rerender = setTimeout(() => { if (player && !$("player-host").hidden) loadPreview(); }, 250);
onChange: (v) => {
if (!player || row) return; // a row's preview stays at its own key
player.setKeyShift(v);
roll.setShift(v);
},
});

Expand Down Expand Up @@ -330,6 +331,7 @@ async function rowPlay(r, item, btn, on) {
mini.setSong(JSON.parse(midi_build_notes_json(bytes, 0)));
seekOnRoll(canvas, mini, player);
activeRoll = mini;
player.setKeyShift(0); // a row plays in the piece's own key
if (!(await player.load(bytes)) || row !== mine) return;
player.play(0);
} catch (err) {
Expand All @@ -347,6 +349,7 @@ function openPicked(title, back) {
stopPreview();
ensurePlayer().unlock(); // inside the click, so the preview can sound when it's ready
activeRoll = roll;
transpose.set(0); // a new piece starts in its own key
$("change-pick").textContent = back;
$("find").hidden = true;
$("picked").hidden = false;
Expand Down Expand Up @@ -532,24 +535,24 @@ function ensurePlayer() {
return player;
}

// Build the .mid for the CURRENT Transpose setting, load it into the player and
// start playback. Re-callable: changing Transpose re-renders with the new shift,
// from the same moment. The `previewGen` token makes a superseded render ignore
// its own async callbacks so rapid re-renders can't race.
// Build the .mid in the piece's own key, load it into the player and start
// playback; Transpose is applied live on top (see `transpose`). The `previewGen`
// token makes a superseded render ignore its own async callbacks so rapid
// re-renders can't race.
async function loadPreview() {
const myGen = ++previewGen;
const semitones = transpose.value;
ensurePlayer().unlock(); // inside the click, before any await
const at = player.playing ? player.time : 0;
try {
if ($("player-host").hidden) setStatus("Loading…");
const bytes = await buildBytes("mid", semitones);
const bytes = await buildBytes("mid", 0);
if (myGen !== previewGen) return;
player.setKeyShift(transpose.value);
if (!(await player.load(bytes)) || myGen !== previewGen) return;
$("player-host").hidden = false;
$("take").hidden = false;
roll.setSong(JSON.parse(midi_build_notes_json(bytes, 0)));
player.play(at);
roll.setShift(transpose.value);
player.play(0);
setStatus("");
} catch (err) {
if (myGen === previewGen) setStatus(`Preview failed: ${err.message || err}`, true);
Expand Down
Loading