149 lines
5.8 KiB
JavaScript
149 lines
5.8 KiB
JavaScript
'use strict';
|
||||
|
|
|
|||
|
|
/**
|
|||
|
|
* The admin page's "Cue lists" text → rules, and MIDI events → what Vista
|
|||
|
|
* was told to do.
|
|||
|
|
*
|
|||
|
|
* One rule per line, the way it reads on a cue sheet:
|
|||
|
|
*
|
|||
|
|
* 60 = Walk-in note 60 fires Go on "Walk-in"
|
|||
|
|
* C3 = Worship 1 note names work too (see "Middle C" in admin)
|
|||
|
|
* 61 release Walk-in release a list
|
|||
|
|
* 62 pause Walk-in also: back, pause, go (the default)
|
|||
|
|
* 127 release all every list off
|
|||
|
|
* ch2:64 = Message only on MIDI channel 2 (otherwise any channel)
|
|||
|
|
* pc5 = Baptism a program change instead of a note
|
|||
|
|
* 65 = "Back Lights" quote a name that starts with an action word
|
|||
|
|
* # anything after a # is a comment
|
|||
|
|
*
|
|||
|
|
* Groups: a line "[Colors]" starts a group, and every rule below it belongs
|
|||
|
|
* to it until the next "[…]" ("[]" ends grouping). A Go in a group replaces
|
|||
|
|
* whichever list of that group was live — firing "Blue" ends "Red" — which is
|
|||
|
|
* how a sheet without release notes still says what is on. The first group
|
|||
|
|
* in the sheet is the headline the tile shows largest.
|
|||
|
|
*
|
|||
|
|
* 30 flash Blinder Flash 1 a momentary hit: logged, never left live
|
|||
|
|
*
|
|||
|
|
* The separator between the trigger and the rest may be "=", "-", ":", "→",
|
|||
|
|
* a tab or just a space, so a column pasted from a spreadsheet works.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const NOTE_NAMES = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
|
|||
|
|
const NAMES_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
|||
|
|
|
|||
|
|
const ACTIONS = [
|
|||
|
|
// Longest first, so "release all" wins over "release".
|
|||
|
|
[/^(release[\s_-]*all|all[\s_-]*off|releaseall|clear)\b/i, 'releaseAll'],
|
|||
|
|
[/^(release|off|stop|kill)\b/i, 'release'],
|
|||
|
|
[/^(pause|hold)\b/i, 'pause'],
|
|||
|
|
[/^(back|previous|prev)\b/i, 'back'],
|
|||
|
|
[/^(flash|hit|momentary)\b/i, 'flash'],
|
|||
|
|
[/^(go|fire|play|start)\b/i, 'go'],
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
/** Octave offset: with middle C (note 60) called "C3", C-2 is note 0; with "C4", C-1 is. */
|
|||
|
|
function octaveBase(middleC) {
|
|||
|
|
return middleC === 'C4' ? 1 : 2;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** "F#2" → note number, or null. */
|
|||
|
|
function noteFromName(token, middleC) {
|
|||
|
|
const m = /^([a-g])(#|♯|b|♭)?(-?\d+)$/i.exec(token);
|
|||
|
|
if (!m) return null;
|
|||
|
|
let pc = NOTE_NAMES[m[1].toLowerCase()];
|
|||
|
|
if (m[2] === '#' || m[2] === '♯') pc += 1;
|
|||
|
|
if (m[2] === 'b' || m[2] === '♭') pc -= 1;
|
|||
|
|
const n = (Number(m[3]) + octaveBase(middleC)) * 12 + pc;
|
|||
|
|
return n >= 0 && n <= 127 ? n : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function noteName(n, middleC) {
|
|||
|
|
return `${NAMES_SHARP[n % 12]}${Math.floor(n / 12) - octaveBase(middleC)}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Parse the whole text. Returns `{ rules, errors, groups }` (groups in sheet order); a line that can't be read
|
|||
|
|
* is reported (line number + why) and skipped, never fatal.
|
|||
|
|
*/
|
|||
|
|
function parseMapping(text, { middleC = 'C3' } = {}) {
|
|||
|
|
const rules = [];
|
|||
|
|
const errors = [];
|
|||
|
|
const groups = [];
|
|||
|
|
let group = '';
|
|||
|
|
String(text || '').split(/\r?\n/).forEach((raw, i) => {
|
|||
|
|
// A comment is a # that starts the line or follows a space ("C#3" and "Song #2" are not).
|
|||
|
|
const line = raw.replace(/(^|\s)#(\s.*|$)/, '').trim();
|
|||
|
|
if (!line) return;
|
|||
|
|
const header = /^\[(.*)\]$/.exec(line);
|
|||
|
|
if (header) {
|
|||
|
|
group = header[1].trim();
|
|||
|
|
if (group && !groups.includes(group)) groups.push(group);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const m = /^(?:ch(?:annel)?\s*(\d{1,2})\s*[:/]\s*)?(pc\s*\d{1,3}|note\s*\d{1,3}|\d{1,3}|[a-g](?:#|♯|b|♭)?-?\d)\s*(?:[=:\t→>|,-]+\s*|\s+|$)(.*)$/i.exec(line);
|
|||
|
|
if (!m) {
|
|||
|
|
errors.push({ line: i + 1, text: raw.trim(), why: 'starts with no note' });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const channel = m[1] ? Number(m[1]) : null;
|
|||
|
|
if (channel != null && (channel < 1 || channel > 16)) {
|
|||
|
|
errors.push({ line: i + 1, text: raw.trim(), why: 'channel must be 1–16' });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const trig = m[2].replace(/\s+/g, '').toLowerCase();
|
|||
|
|
let kind = 'note';
|
|||
|
|
let number;
|
|||
|
|
if (trig.startsWith('pc')) {
|
|||
|
|
kind = 'program';
|
|||
|
|
number = Number(trig.slice(2));
|
|||
|
|
} else if (trig.startsWith('note')) {
|
|||
|
|
number = Number(trig.slice(4));
|
|||
|
|
} else if (/^\d+$/.test(trig)) {
|
|||
|
|
number = Number(trig);
|
|||
|
|
} else {
|
|||
|
|
number = noteFromName(m[2].replace(/\s+/g, ''), middleC);
|
|||
|
|
}
|
|||
|
|
if (number == null || !Number.isInteger(number) || number < 0 || number > 127) {
|
|||
|
|
errors.push({ line: i + 1, text: raw.trim(), why: 'note must be 0–127' });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
let rest = m[3].trim();
|
|||
|
|
let action = 'go';
|
|||
|
|
const quoted = (t) => /^"(.*)"$/.exec(t);
|
|||
|
|
for (const [re, a] of quoted(rest) ? [] : ACTIONS) {
|
|||
|
|
const am = re.exec(rest);
|
|||
|
|
if (am) {
|
|||
|
|
action = a;
|
|||
|
|
rest = rest.slice(am[0].length).replace(/^[\s=:→>|,-]+/, '').trim();
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const q = quoted(rest);
|
|||
|
|
if (q) rest = q[1].trim();
|
|||
|
|
if (action !== 'releaseAll' && !rest) {
|
|||
|
|
errors.push({ line: i + 1, text: raw.trim(), why: 'no cue list name' });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
rules.push({ line: i + 1, channel, kind, number, action, list: action === 'releaseAll' ? '' : rest, group });
|
|||
|
|
});
|
|||
|
|
return { rules, errors, groups };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** The first rule a MIDI event matches, or null. Only note-ons and program changes fire anything. */
|
|||
|
|
function match(rules, event) {
|
|||
|
|
let kind;
|
|||
|
|
let number;
|
|||
|
|
if (event.type === 'noteon') { kind = 'note'; number = event.note; }
|
|||
|
|
else if (event.type === 'program') { kind = 'program'; number = event.program; }
|
|||
|
|
else return null;
|
|||
|
|
return rules.find((r) => r.kind === kind && r.number === number && (r.channel == null || r.channel === event.channel)) || null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** "Note 60 (C3) · ch 1" — how an event is named on the tile. */
|
|||
|
|
function triggerLabel(event, middleC) {
|
|||
|
|
if (event.type === 'program') return `PC ${event.program} · ch ${event.channel}`;
|
|||
|
|
return `Note ${event.note} (${noteName(event.note, middleC)}) · ch ${event.channel}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = { parseMapping, match, noteName, noteFromName, triggerLabel };
|