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