Files
ProdDash/modules/vista-cues/mapping.js
T

149 lines
5.8 KiB
JavaScript
Raw Normal View History

'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 };