Commit ad30f3d5 by PLN (Algolia)

feat(tools): vendor pulsar-parvagues-hud package

Pulsar editor plugin rendering a track-aware HUD topbar
(role colors, LCXL spatial layout, header_comments). Was
sitting loose at ~/Work/Tools/pulsar-parvagues-hud with no
git; vendored into tools/ so the package travels with the
livecoding workspace and can be shared in one clone.

node_modules excluded; run `apm install` (or `npm install`)
inside the dir to set up.
parent fa4ab3d0
{
"atom-workspace": {
"ctrl-alt-h": "parvagues-hud:toggle",
"ctrl-alt-g": "parvagues-hud:toggle-groups",
"ctrl-alt-r": "parvagues-hud:refresh"
}
}
'use babel';
// Novation LaunchControl XL factory-template CC layout.
// Source of truth: reference_controller_map.md.
//
// Lane: 1 2 3 4 5 6 7 8
// Top knobs: 13 14 15 16 17 18 19 20 (row A)
// Mid knobs: 29 30 31 32 33 34 35 36 (row B)
// Bot knobs: 49 50 51 52 53 54 55 56 (row C)
// Faders: 77 78 79 80 81 82 83 84 (row D)
// State btns: 41 42 43 44 57 58 59 60 (row E)
// Push btns: 73 74 75 76 89 90 91 92 (row F)
const TABLE = [
{ row: 'A', start: 13, lanes: [1, 2, 3, 4, 5, 6, 7, 8] },
{ row: 'B', start: 29, lanes: [1, 2, 3, 4, 5, 6, 7, 8] },
{ row: 'C', start: 49, lanes: [1, 2, 3, 4, 5, 6, 7, 8] },
{ row: 'D', start: 77, lanes: [1, 2, 3, 4, 5, 6, 7, 8] },
// E and F have gaps — split into two contiguous segments each.
{ row: 'E', start: 41, lanes: [1, 2, 3, 4] },
{ row: 'E', start: 57, lanes: [5, 6, 7, 8] },
{ row: 'F', start: 73, lanes: [1, 2, 3, 4] },
{ row: 'F', start: 89, lanes: [5, 6, 7, 8] }
];
export function decodeCC(cc) {
const n = Number(cc);
for (const seg of TABLE) {
const idx = n - seg.start;
if (idx >= 0 && idx < seg.lanes.length) {
const lane = seg.lanes[idx];
return { row: seg.row, lane, label: `${seg.row}${lane}`, cc: n };
}
}
return { row: '?', lane: null, label: `cc${n}`, cc: n };
}
// Reverse: given a (row, lane), return the matching CC. Used by the
// convention map in render so we can show an orbit's expected physical
// control even when the .tidal file has no explicit "^NN" reference.
export function encodeCC(row, lane) {
for (const seg of TABLE) {
if (seg.row !== row) continue;
const idx = seg.lanes.indexOf(lane);
if (idx >= 0) return seg.start + idx;
}
return null;
}
'use babel';
import { CompositeDisposable } from 'atom';
import { parse } from './parser';
import { render } from './render';
const fs = require('fs');
const path = require('path');
export default {
subscriptions: null,
panel: null,
hudEl: null,
activeEditorSub: null,
activate() {
this.subscriptions = new CompositeDisposable();
this.hudEl = document.createElement('div');
this.hudEl.classList.add('parvagues-hud');
this.panel = atom.workspace.addTopPanel({
item: this.hudEl,
visible: true,
priority: 100
});
// Sticky mode: when the user focuses a non-tidal pane (e.g. backlog.md),
// keep the last loaded HUD visible instead of blanking. The HUD only
// updates when a new .tidal editor becomes active or that editor saves.
this._lastTidalEditor = null;
render(this.hudEl, null, { idle: true });
this.subscriptions.add(
atom.workspace.observeActiveTextEditor((editor) => {
// Defensive: editors fired by observeActiveTextEditor during
// startup may not be fully constructed yet (no .element, getPath
// still null, etc.). Wrap the whole flow so we never throw out
// of an Atom subscription callback — that can corrupt Pulsar's
// internal text-editor-component state.
try { this._attachToEditor(editor); }
catch (e) { /* swallow — HUD is non-essential */ }
})
);
this.subscriptions.add(
atom.commands.add('atom-workspace', {
'parvagues-hud:toggle': () => this._toggle(),
'parvagues-hud:refresh': () => this._refresh(),
'parvagues-hud:toggle-groups': () => this._toggleGroups()
})
);
this._showGroups = atom.config.get('pulsar-parvagues-hud.showGroups');
this.subscriptions.add(
atom.config.observe('pulsar-parvagues-hud.showGroups', (v) => {
this._showGroups = v;
this._refresh();
})
);
// Load BootTidal.hs once — its global `let` block defines gMask/gMute*/gF*
// for every track. Cached for the session; reload Pulsar window to pick
// up edits to BootTidal.hs (rare).
this._bootGroups = this._loadBootGroups();
},
_loadBootGroups() {
const candidates = [];
try {
const cfg = atom.config.get('tidalcycles.bootTidalPath');
if (cfg) candidates.push(cfg);
} catch (e) {}
try {
for (const root of atom.project.getPaths()) {
candidates.push(path.join(root, 'BootTidal.hs'));
}
} catch (e) {}
const home = process.env.HOME || '';
if (home) {
candidates.push(path.join(home, '.config', 'tidalcycles', 'BootTidal.hs'));
candidates.push(path.join(home, '.tidal', 'BootTidal.hs'));
}
for (const p of candidates) {
try {
if (p && fs.existsSync(p)) {
const text = fs.readFileSync(p, 'utf8');
const parsed = parse(text);
return parsed.groups || {};
}
} catch (e) {}
}
return {};
},
deactivate() {
if (this.activeEditorSub) {
this.activeEditorSub.dispose();
this.activeEditorSub = null;
}
if (this.subscriptions) {
this.subscriptions.dispose();
this.subscriptions = null;
}
if (this.panel) {
this.panel.destroy();
this.panel = null;
}
this.hudEl = null;
},
_isTidalFile(editor) {
try {
if (!editor || typeof editor.getPath !== 'function') return false;
const path = editor.getPath();
const ext = atom.config.get('pulsar-parvagues-hud.filenameGlob') || '.tidal';
return !!(path && path.endsWith(ext));
} catch (e) { return false; }
},
_parseSafely(text) {
try {
return parse(text);
} catch (e) {
return { groups: {}, orbits: [], errors: [`parser threw: ${e.message}`] };
}
},
_attachToEditor(editor) {
if (this.activeEditorSub) {
this.activeEditorSub.dispose();
this.activeEditorSub = null;
}
this._currentEditor = editor;
if (!this._isTidalFile(editor)) {
// Sticky: don't blank the HUD. Keep whatever was last loaded.
return;
}
this._lastTidalEditor = editor;
this._refresh();
try {
const sub = editor.onDidSave(() => {
try { this._refresh(); } catch (e) { /* HUD must never crash editor */ }
});
this.activeEditorSub = sub;
this.subscriptions.add(sub);
} catch (e) {
// Editor was disposed or not yet ready — skip the save subscription.
}
},
_refresh() {
if (!this.hudEl) return;
// Prefer the active tidal editor; fall back to the last one we saw so
// a manual refresh from a non-tidal pane still re-parses the HUD's content.
const editor = this._isTidalFile(this._currentEditor)
? this._currentEditor
: this._lastTidalEditor;
if (!editor) {
render(this.hudEl, null, { idle: true });
return;
}
const state = this._parseSafely(editor.getText());
// Merge BootTidal.hs groups as baseline; per-track overrides win.
const mergedGroups = { ...(this._bootGroups || {}), ...(state.groups || {}) };
render(this.hudEl, { ...state, groups: mergedGroups }, { showGroups: this._showGroups });
},
_toggle() {
if (!this.panel) return;
if (this.panel.isVisible()) this.panel.hide();
else this.panel.show();
},
_toggleGroups() {
this._showGroups = !this._showGroups;
atom.config.set('pulsar-parvagues-hud.showGroups', this._showGroups);
this._refresh();
}
};
'use babel';
import { decodeCC } from './lcxl-map';
import { nearestKeywordBefore, classifyBinding, classifyEffectForCC, GROUPS } from './tidal-keywords';
import { classifyRole } from './role-classifier';
// Orbit declarations are top-level (column 0) in real ParVagues tracks.
// Match `d1 $ ...`, `d12 $ ...`, OR `d4` on a line by itself (with the `$`
// continuing on the next indented line — seen in bullet_train.tidal).
const ORBIT_START_RE = /^d(\d+)(?:\s|\$|$)/;
const CC_RE = /"\^(\d+)"/g;
// Group definitions can appear as:
// let gMute1 = (...) -- single let
// let -- multi-let block (BootTidal.hs)
// gMute1 = (...)
// gMute2 = (...)
// We accept either: optional leading `let` then the group name.
const LET_GROUP_RE = /^\s*(?:let\s+)?(gMask|gMute[123]|gM[123]|gF[123])\s*=/;
const GROUP_NAMES = ['gMask', 'gMute1', 'gMute2', 'gMute3', 'gM1', 'gM2', 'gM3', 'gF1', 'gF2', 'gF3'];
const GROUP_USAGE_RE = new RegExp('\\b(' + GROUP_NAMES.join('|') + ')\\b', 'g');
// Find the balanced-paren group starting at offset `start` in s
// (s[start] must be '('). Returns the substring between the parens, or null.
function balancedParen(s, start) {
if (s[start] !== '(') return null;
let depth = 0;
for (let i = start; i < s.length; i++) {
if (s[i] === '(') depth++;
else if (s[i] === ')') {
depth--;
if (depth === 0) return s.slice(start + 1, i).trim();
}
}
return null;
}
// Pull out a short context string for a "^NN" binding so the HUD pill
// can show effect + the meaningful value, not just the effect name.
// Ontology-driven — what matters depends on what wraps the CC:
// struct "pattern" → the rhythmic pattern (what "on" actually plays)
// mask "pattern" → the mask pattern (what's let through)
// range MIN MAX → MIN→MAX (the value sweep)
// ply/slow/fast N → "ply N" / "slow N" (the multiplier)
// sometimesBy P (X) → P% + (truncated X)
// anything else → first quoted string, else first 14 chars verbatim
function ontologyContent(inner) {
if (!inner) return null;
let m;
// struct / mask: the quoted pattern IS the rhythm
if ((m = inner.match(/^(struct|mask)\s+"([^"]+)"/))) return m[2];
// # effect VALUE — sometimesBy / etc. apply a Tidal modifier expressed
// as `# name X`. Include the value so the user can see what kind of
// squiz/cut/etc. at a glance, not just the effect name.
// # squiz "<1 2 3 1>" → squiz <1 2 3 1>
// # cut 41 → cut 41
// # n "29" → n 29
if ((m = inner.match(/^#\s+([a-zA-Z_]\w*)(?:\s+(.+))?$/))) {
const effect = m[1];
const rest = (m[2] || '').trim();
if (!rest) return effect;
const qm = rest.match(/^"([^"]+)"$/);
return qm ? `${effect} ${qm[1]}` : `${effect} ${rest}`;
}
// ply / slow / fast / chop / slice / loopAt / iter / stut / echo
if ((m = inner.match(/^(ply|slow|fast|chop|slice|loopAt|iter|stut|echo)\s+("[^"]+"|\S+)/))) {
return `${m[1]} ${m[2].replace(/"/g, '')}`;
}
// sometimesBy P (X) — keep probability when explicit
if ((m = inner.match(/^(sometimesBy|someCyclesBy)\s+([0-9.]+)/))) {
return `${Math.round(parseFloat(m[2]) * 100)}%`;
}
// Generic: first quoted string anywhere
if ((m = inner.match(/"([^"]+)"/))) return m[1];
return inner;
}
function extractBindingContent(line, ccIndex, ccLen) {
// Range case: `range MIN MAX "^NN"` — value-sweep idiom.
const before = line.slice(0, ccIndex);
const rangeM = before.match(/range\s+(-?[\d.]+|"[^"]+")\s+(-?[\d.]+|"[^"]+")\s*$/);
if (rangeM) {
return rangeM[1].replace(/"/g, '') + '→' + rangeM[2].replace(/"/g, '');
}
// Trigger case: `<trigger> "^NN" (...)` — the paren content is the action.
const after = line.slice(ccIndex + ccLen);
const parenStart = after.search(/\(/);
if (parenStart >= 0) {
const inner = balancedParen(after, parenStart);
if (inner) return ontologyContent(inner);
}
return null;
}
// Detect the bare sample/synth string for an orbit block.
// Returns the most informative candidate, or null.
function extractSound(blockLines) {
let bareCandidate = null;
let nameCandidate = null;
// Both candidates must come from the orbit's TOP-LEVEL composition chain,
// not from a string nested inside parens (e.g. inside a midiOn (...)
// alternative branch). Top-level lines look like ` $ "..."` or ` # "..."`.
for (let i = 0; i < blockLines.length; i++) {
const line = blockLines[i];
if (line.trim().startsWith('--')) continue;
const topBare = line.match(/^\s*\$\s+"([^"]+)"/);
if (topBare) bareCandidate = topBare[1];
const topHash = line.match(/^\s*#\s+"([^"]+)"/);
if (topHash) {
const v = topHash[1];
if (/^[a-zA-Z_][\w]*(:\d+)?$/.test(v) || /^\[[^\]]+\]$/.test(v)) {
nameCandidate = v;
}
}
}
// Synth/sample identity (# "name") wins over the bare pattern when
// present — the user wants to see "bassWarsaw"/"orage", not "<1 2 3>".
return nameCandidate || bareCandidate || null;
}
function extractInlineLabel(headerLine) {
const m = headerLine.match(/^d\d+.*?--\s*(.+?)\s*$/);
if (!m) return null;
let label = m[1].trim();
// Strip self-notes ("TODO …", "FIXME …") — they're not part of the
// track's identity, just reminders from the author.
label = label.replace(/\s+(TODO|FIXME|HACK|XXX|NOTE)\b.*$/i, '').trim();
return label || null;
}
// Collect which file-level groups (gMask, gMute1-3, gM1-3, gF1-3) this
// orbit composes with. Used by the HUD to render "who's affected" lists
// on the group cells (e.g. F1 cell shows [d2 d3 d8] — orbits whose mute
// toggles when CC 73 fires).
function extractOrbitUses(blockLines) {
const out = new Set();
for (const line of blockLines) {
if (line.trim().startsWith('--')) continue;
let m;
GROUP_USAGE_RE.lastIndex = 0;
while ((m = GROUP_USAGE_RE.exec(line)) !== null) out.add(m[1]);
}
return Array.from(out);
}
function extractBindingsFromBlock(blockLines, ctx) {
const bindings = [];
for (const line of blockLines) {
if (line.trim().startsWith('--')) continue;
let m;
CC_RE.lastIndex = 0;
while ((m = CC_RE.exec(line)) !== null) {
const cc = Number(m[1]);
const prefix = line.slice(0, m.index);
const keyword = nearestKeywordBefore(prefix);
// If the keyword is a GROUP name (gM1 etc.), the binding is inherited
// from the file-level let; skip it for per-orbit bindings.
if (keyword && GROUPS.includes(keyword)) continue;
const control = decodeCC(cc);
const effect = classifyEffectForCC(line, m.index);
const content = extractBindingContent(line, m.index, m[0].length);
// De-duplicate within an orbit by (cc + effect + content): same
// binding collapses, but different patterns under the same effect
// (e.g. midiOn vs midiOff structs on same CC) stay separate.
const key = `${cc}|${effect}|${content || ''}`;
if (!bindings.some(b => `${b.cc}|${b.effect}|${b.content || ''}` === key)) {
bindings.push({ cc, control, effect, content });
}
}
}
return bindings;
}
function extractGroups(allLines) {
const groups = {};
for (const line of allLines) {
const m = line.match(LET_GROUP_RE);
if (!m) continue;
const name = m[1];
// Find first "^NN" on the RHS.
CC_RE.lastIndex = 0;
const cm = CC_RE.exec(line);
if (!cm) continue;
const cc = Number(cm[1]);
const control = decodeCC(cc);
const effect = classifyEffectForCC(line, cm.index);
groups[name] = { cc, control, effect };
}
return groups;
}
export function parse(text) {
const errors = [];
let lines;
try {
lines = (text || '').split(/\r?\n/);
} catch (e) {
return { groups: {}, orbits: [], errors: [`split failed: ${e.message}`] };
}
// Pass 1: file-level groups.
let groups = {};
try {
groups = extractGroups(lines);
} catch (e) {
errors.push(`groups: ${e.message}`);
}
// Pass 2: identify orbit block boundaries.
const blocks = [];
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(ORBIT_START_RE);
if (m) blocks.push({ n: Number(m[1]), startLine: i });
}
// Close blocks: end = next start, or EOF.
for (let i = 0; i < blocks.length; i++) {
blocks[i].endLine = (i + 1 < blocks.length) ? blocks[i + 1].startLine : lines.length;
}
const orbits = [];
for (const b of blocks) {
try {
const blockLines = lines.slice(b.startLine, b.endLine);
const name = extractInlineLabel(lines[b.startLine]);
const sound = extractSound(blockLines);
const bindings = extractBindingsFromBlock(blockLines, { groups });
const uses = extractOrbitUses(blockLines);
orbits.push({ n: b.n, name, sound, bindings, uses, startLine: b.startLine + 1 });
} catch (e) {
errors.push(`d${b.n}: ${e.message}`);
}
}
// Tracks frequently have scratch alternates at the bottom (e.g.
// `d1 $ n "e e a e" # s "bass"`). Treating each as a separate orbit
// produces duplicates in the HUD. Merge by orbit number: union the
// bindings, take the FIRST seen sound/name (top-of-file wins).
const byN = {};
for (const o of orbits) {
if (!byN[o.n]) {
byN[o.n] = { ...o, bindings: [...o.bindings], uses: [...(o.uses || [])] };
} else {
const merged = byN[o.n];
for (const b of o.bindings) {
if (!merged.bindings.some(x => x.cc === b.cc && x.effect === b.effect)) {
merged.bindings.push(b);
}
}
for (const u of (o.uses || [])) {
if (!merged.uses.includes(u)) merged.uses.push(u);
}
if (!merged.sound) merged.sound = o.sound;
if (!merged.name) merged.name = o.name;
}
}
const dedupedOrbits = Object.values(byN).sort((a, b) => a.n - b.n);
// Classify each orbit's role (rhythm/bass/lead/pad/riser) for HUD color.
for (const o of dedupedOrbits) {
const r = classifyRole(o);
o.role = r.role;
o.roleSource = r.source;
o.roleMatched = r.matched;
}
return { groups, orbits: dedupedOrbits, errors };
}
'use babel';
import { encodeCC } from './lcxl-map';
// ParVagues convention: each orbit has a default physical "home" on the
// LaunchControl XL surface. Per user spec:
// d1..d5, d7, d8 → faders D2..D8 (row D, volumes)
// d6 → mid-knob B3 (cc31) — exception, off the fader row
// d9 → top-knob A8 (cc20)
// d10..d12 → top-knobs A1..A3 (cc13..15)
//
// The HUD reflects this by putting orbit labels exactly where the user's
// hands sit on the controller: row-A orbits in a TOP header band, row-D
// orbits in a BOTTOM header band, anything else (e.g. d6) as a chip
// embedded directly in the cell.
const ORBIT_CONVENTION = {
1: { lane: 2, row: 'D' },
2: { lane: 3, row: 'D' },
3: { lane: 4, row: 'D' },
4: { lane: 5, row: 'D' },
5: { lane: 6, row: 'D' },
6: { lane: 3, row: 'B' },
7: { lane: 7, row: 'D' },
8: { lane: 8, row: 'D' },
9: { lane: 8, row: 'A' },
10: { lane: 1, row: 'A' },
11: { lane: 2, row: 'A' },
12: { lane: 3, row: 'A' }
};
const ROWS = ['A', 'B', 'C', 'D', 'E', 'F'];
// Canonical group definitions. Most ParVagues tracks don't `let`-define
// gMute1/gMask/gF1/etc. in the .tidal file — they inherit them from
// BootTidal.hs. We render the canonical cells unconditionally so the
// group rows F1-F3 / E1 / C1-C3 are always present, and let any local
// `let` override the defaults.
const CANONICAL_GROUPS = {
gMask: { cc: 41, control: { row: 'E', lane: 1, label: 'E1' }, effect: 'on' },
gMute1: { cc: 73, control: { row: 'F', lane: 1, label: 'F1' }, effect: 'mute' },
gMute2: { cc: 74, control: { row: 'F', lane: 2, label: 'F2' }, effect: 'mute' },
gMute3: { cc: 75, control: { row: 'F', lane: 3, label: 'F3' }, effect: 'mute' },
gF1: { cc: 49, control: { row: 'C', lane: 1, label: 'C1' }, effect: 'djf' },
gF2: { cc: 50, control: { row: 'C', lane: 2, label: 'C2' }, effect: 'djf' },
gF3: { cc: 51, control: { row: 'C', lane: 3, label: 'C3' }, effect: 'djf' }
};
function effectiveGroups(state) {
const out = {};
for (const [name, def] of Object.entries(CANONICAL_GROUPS)) {
out[name] = (state.groups && state.groups[name]) || def;
}
// Allow any additional non-canonical groups parsed from the file too.
if (state.groups) {
for (const [name, g] of Object.entries(state.groups)) {
if (!out[name]) out[name] = g;
}
}
return out;
}
function el(tag, opts = {}) {
const e = document.createElement(tag);
if (opts.cls) e.className = opts.cls;
if (opts.text != null) e.textContent = opts.text;
if (opts.title) e.title = opts.title;
if (opts.style) for (const k in opts.style) e.style[k] = opts.style[k];
return e;
}
function truncate(s, n) {
if (!s) return '';
return s.length > n ? s.slice(0, n - 1) + '…' : s;
}
// Build a sparse 2D map: grid[lane][row] = [ {effect, orbit?, group?, cc} ]
function buildGrid(state) {
const grid = {};
const placeCell = (lane, row, payload) => {
if (!lane || row === '?') return;
grid[lane] ??= {};
grid[lane][row] ??= [];
grid[lane][row].push(payload);
};
// Index orbits by number so binding pills can pick up the orbit's role.
const orbitsByN = {};
for (const o of state.orbits || []) orbitsByN[o.n] = o;
for (const o of state.orbits || []) {
for (const b of o.bindings || []) {
placeCell(b.control.lane, b.control.row, {
effect: b.effect, cc: b.cc, orbit: o.n, content: b.content,
role: o.role
});
}
}
// Render every canonical group cell (gMask/gMute*/gF*) — main.js merges
// BootTidal.hs into state.groups, and effectiveGroups falls back to
// hardcoded canonicals if BootTidal.hs wasn't found.
for (const [name, g] of Object.entries(effectiveGroups(state))) {
const userNs = orbitsAffectedByGroup(name, state.orbits || []);
placeCell(g.control.lane, g.control.row, {
effect: g.effect, cc: g.cc, group: name,
users: userNs,
userRoles: userNs.map(n => orbitsByN[n] ? orbitsByN[n].role : null)
});
}
return grid;
}
// Which orbits are affected when this group's CC toggles.
// gMute1 (cc73): orbits using gMute1 directly OR gM1 (= gMask . gMute1)
// gMask (cc41): orbits using gMask OR any gMx (gM1/gM2/gM3 all chain through gMask)
// gF1 (cc49): orbits using gF1 (djf bus 1 sweep)
function orbitsAffectedByGroup(groupName, orbits) {
const equiv = {
gMask: ['gMask', 'gM1', 'gM2', 'gM3'],
gMute1: ['gMute1', 'gM1'],
gMute2: ['gMute2', 'gM2'],
gMute3: ['gMute3', 'gM3'],
gM1: ['gMute1', 'gM1'],
gM2: ['gMute2', 'gM2'],
gM3: ['gMute3', 'gM3'],
gF1: ['gF1'],
gF2: ['gF2'],
gF3: ['gF3']
};
const targets = new Set(equiv[groupName] || [groupName]);
return orbits
.filter(o => o.uses && o.uses.some(u => targets.has(u)))
.map(o => o.n)
.sort((a, b) => a - b);
}
// Every orbit's label is rendered INSIDE its convention cell. Lookup:
// inline[lane][row] = [orbit, …]. unplaced are orbits not in the
// convention map (rare — typically d13+ or off-spec numbering).
function classifyPlacements(orbits) {
const inline = {};
const unplaced = [];
for (const o of orbits) {
const conv = ORBIT_CONVENTION[o.n];
if (!conv) { unplaced.push(o); continue; }
const inLane = (inline[conv.lane] ??= {});
(inLane[conv.row] ??= []).push(o);
}
return { inline, unplaced };
}
// Column accent: prefer the fader-row orbit (row D), then top-knob (A),
// then any inline orbit. Used for orbit-color cell tinting.
function laneAccentOrbit(lane, placements) {
const inl = placements.inline[lane];
if (!inl) return null;
if (inl['D'] && inl['D'].length) return inl['D'][0];
if (inl['A'] && inl['A'].length) return inl['A'][0];
for (const row of ROWS) {
if (inl[row] && inl[row].length) return inl[row][0];
}
return null;
}
function orbitChip(o, opts = {}) {
const roleCls = o.role ? ` pv-role-${o.role}` : '';
const chip = el('span', { cls: `pv-orbit-chip pv-orbit-d${o.n}${roleCls}` });
chip.appendChild(el('span', { cls: 'pv-orbit-n', text: `d${o.n}` }));
if (o.name && !opts.noName) {
chip.appendChild(el('span', {
cls: 'pv-orbit-name', text: truncate(o.name, opts.nameMax || 22),
title: o.name
}));
}
if (o.sound && !opts.noSound) {
chip.appendChild(el('span', {
cls: 'pv-orbit-sound',
text: `"${truncate(o.sound, opts.soundMax || 20)}"`,
title: o.sound
}));
}
return chip;
}
function headerCell(lane, orbits, position) {
const cell = el('div', { cls: `pv-col-head pv-col-head-${position}` });
if (!orbits || !orbits.length) {
cell.appendChild(el('span', { cls: 'pv-dim', text: '·' }));
return cell;
}
for (const o of orbits) cell.appendChild(orbitChip(o));
return cell;
}
function bindingPill(c, primaryOrbitN) {
const pill = el('span', { cls: 'pv-effect' });
let badge;
let titleSuffix = c.content ? ` · ${c.content}` : '';
if (c.group) {
pill.classList.add('pv-from-group');
badge = groupShortName(c.group);
const userList = (c.users && c.users.length) ? c.users.map(n => 'd' + n).join(' ') : 'none';
pill.title = `CC ${c.cc} · ${c.group}${c.effect}\nAffects: ${userList}`;
pill.appendChild(el('span', { cls: 'pv-effect-badge', text: badge }));
if (c.users && c.users.length) {
// Compact format: `d 2 3 8` where the `d` is gray and each digit is
// colored by its orbit's role. For two-digit orbit numbers (10-12)
// we use a thin separator so they don't run together.
const hasDouble = c.users.some(n => n >= 10);
const list = el('span', { cls: 'pv-effect-content pv-group-users' });
list.appendChild(el('span', { cls: 'pv-orbit-list-d', text: 'd' }));
c.users.forEach((n, i) => {
const role = (c.userRoles && c.userRoles[i]) || null;
const sep = (i > 0 && hasDouble) ? '·' : '';
if (sep) list.appendChild(el('span', { cls: 'pv-orbit-list-sep', text: sep }));
list.appendChild(el('span', {
cls: `pv-orbit-digit pv-orbit-d${n}` + (role ? ` pv-role-${role}` : ''),
text: String(n),
title: `d${n} affected by ${c.group} (CC ${c.cc})${role ? ' · ' + role : ''}`
}));
});
pill.appendChild(list);
}
return pill;
} else {
pill.classList.add(`pv-orbit-d${c.orbit}`);
if (c.role) pill.classList.add(`pv-role-${c.role}`);
badge = c.effect;
pill.title = `CC ${c.cc} · d${c.orbit}${c.effect}${titleSuffix}`;
// "Off-lane" = the binding's orbit (d{c.orbit}) is NOT the column's
// home orbit (d{primaryOrbitN}). Visual cue is the pill's color
// stripe matching its own orbit, contrasting with the column tint.
if (primaryOrbitN && c.orbit !== primaryOrbitN) {
pill.classList.add('pv-off-lane');
pill.title += ` (d${c.orbit}'s binding, displayed in d${primaryOrbitN}'s column)`;
}
}
pill.appendChild(el('span', { cls: 'pv-effect-badge', text: badge }));
if (c.content) {
pill.appendChild(el('span', {
cls: 'pv-effect-content',
text: truncate(c.content, 22),
title: c.content
}));
}
return pill;
}
function bodyCell(lane, row, cells, inlineOrbits, accentOrbit) {
const wrap = el('div', { cls: 'pv-cell' });
wrap.dataset.lane = lane;
wrap.dataset.row = row;
// Special-case: D1 fader = global Ardour master volume, not an orbit.
// Show a faint "Master" label so the lane reads coherently.
if (row === 'D' && lane === 1 && (!inlineOrbits || !inlineOrbits.length)) {
wrap.classList.add('pv-cell-master');
wrap.appendChild(el('span', { cls: 'pv-master-label', text: 'Master', title: 'Ardour master volume' }));
if (cells && cells.length) {
for (const c of cells) wrap.appendChild(bindingPill(c, accentOrbit ? accentOrbit.n : null));
}
return wrap;
}
// Inline orbit labels (e.g. d6 at B3, d1 at D2, d10 at A1) appear FIRST
// so the cell reads "d4 bassWarsaw [pills…]" left-to-right.
if (inlineOrbits && inlineOrbits.length) {
for (const o of inlineOrbits) {
const roleCls = o.role ? ` pv-role-${o.role}` : '';
const chip = el('span', { cls: `pv-inline-orbit pv-orbit-d${o.n}${roleCls}` });
chip.appendChild(el('span', { cls: 'pv-orbit-n', text: `d${o.n}` }));
if (o.name) {
chip.appendChild(el('span', {
cls: 'pv-orbit-name', text: truncate(o.name, 18), title: o.name
}));
}
if (o.sound) {
chip.appendChild(el('span', {
cls: 'pv-orbit-sound', text: truncate(o.sound, 16), title: o.sound
}));
}
const roleNote = o.role ? `\nrole: ${o.role}${o.roleMatched ? ` (matched "${o.roleMatched}")` : ''}` : '';
chip.title = `d${o.n}${o.name ? ' ' + o.name : ''}${o.sound ? ' "' + o.sound + '"' : ''}${roleNote}`;
wrap.appendChild(chip);
}
}
if (!cells || !cells.length) {
if (!inlineOrbits || !inlineOrbits.length) {
wrap.classList.add('pv-cell-empty');
wrap.appendChild(el('span', { cls: 'pv-cell-dot', text: '·' }));
}
return wrap;
}
// De-duplicate by effect+source so two midiOn-same-cc don't double up.
const seen = new Set();
const uniq = cells.filter(c => {
const k = c.effect + (c.group ?? '') + (c.orbit ?? '');
if (seen.has(k)) return false;
seen.add(k); return true;
});
for (const c of uniq) {
wrap.appendChild(bindingPill(c, accentOrbit ? accentOrbit.n : null));
}
return wrap;
}
// Map gMask/gMute1-3/gM1-3/gF1-3 to perf-readable suffixes so the group
// pill reads "mute1" / "mute2" instead of a generic "mute*".
function groupShortName(name) {
if (!name) return '';
if (name === 'gMask') return 'mask';
const m = name.match(/^gMute([123])$/);
if (m) return 'mute' + m[1];
const m2 = name.match(/^gM([123])$/);
if (m2) return 'mute' + m2[1]; // gM1 = gMask . gMute1 → same channel
const m3 = name.match(/^gF([123])$/);
if (m3) return 'djf' + m3[1];
return name;
}
function buildGridUI(state) {
const grid = buildGrid(state);
const placements = classifyPlacements(state.orbits || []);
const root = el('div', { cls: 'pv-grid' });
// 6 rows A B C D E F. Every orbit label sits IN its convention cell
// (faders for d1-d8, top knobs for d9-d12, mid knob B3 for d6) — no
// separate header bands. D1 cell shows "Master" since that fader is
// routed to the Ardour master, not a Tidal orbit.
for (const row of ROWS) {
root.appendChild(el('div', { cls: `pv-row-label pv-row-label-${row}`, text: row }));
for (let lane = 1; lane <= 8; lane++) {
const cells = grid[lane]?.[row];
const inlineOrbits = placements.inline[lane]?.[row];
const accent = laneAccentOrbit(lane, placements);
const cell = bodyCell(lane, row, cells, inlineOrbits, accent);
if (accent) cell.classList.add(`pv-col-owned-d${accent.n}`);
root.appendChild(cell);
}
}
return root;
}
// Orphans: orbits not in the convention map — usually d13+ or off-spec
// numbering. Render as a small footer below the grid.
function buildOrphansRow(state) {
const placements = classifyPlacements(state.orbits || []);
if (!placements.unplaced.length) return null;
const row = el('div', { cls: 'pv-orphans-row' });
row.appendChild(el('span', { cls: 'pv-section-label', text: 'unplaced' }));
for (const o of placements.unplaced) row.appendChild(orbitChip(o));
return row;
}
function errorsBadge(errors) {
if (!errors || !errors.length) return null;
return el('span', {
cls: 'pv-error-badge',
text: `⚠ ${errors.length} parse warning${errors.length > 1 ? 's' : ''}`,
title: errors.join('\n')
});
}
// state: parser output, OR null to render empty.
export function render(root, state, opts = {}) {
while (root.firstChild) root.removeChild(root.firstChild);
if (opts.idle) {
root.appendChild(el('span', { cls: 'pv-dim', text: opts.idleMessage || 'ParVagues HUD — no .tidal active' }));
return;
}
if (!state) {
root.appendChild(el('span', { cls: 'pv-dim', text: 'ParVagues HUD — empty' }));
return;
}
root.appendChild(buildGridUI(state));
const orphans = buildOrphansRow(state);
if (orphans) root.appendChild(orphans);
const badge = errorsBadge(state.errors);
if (badge) root.appendChild(badge);
}
'use babel';
// Lightweight role ontology for ParVagues orbits. Five buckets keyed off
// (1) sample-name content, (2) group-routing hints, (3) orbit-number
// convention. Role drives the HUD color palette — at-a-glance scannability
// trumps unique-per-orbit identity.
export const ROLE_RHYTHM = 'rhythm';
export const ROLE_BASS = 'bass';
export const ROLE_LEAD = 'lead';
export const ROLE_PAD = 'pad';
export const ROLE_RISER = 'riser';
// Pattern order matters: more specific keywords first, generic catch-alls
// last. `riser` before `rhythm` so "risers:N" doesn't get eaten by the
// drum-y wordlist; `rhythm` before `bass` so "808bd" stays a kick.
const PATTERNS = [
{ role: ROLE_RISER, re: /\b(risers?|sweep|whoosh|uplifter|fx_uplift)/i },
{ role: ROLE_RHYTHM, re: /\b(kick|^bd|:bd|808bd|808bb|sn|snare|hh|oh|ch|hat|breaks?|drums?|dr[:_]|808|clap|cymbal|tom|perc|rim|cowbell|casio:0|electro|jungle_breaks|gretsch|vec\d+_snare|cp[:_])/i },
{ role: ROLE_BASS, re: /\b(bass|sub|cbow|cpluck|moogbass|basswarsaw|bassdm|acid|cb[:_]|bassdrum)/i },
{ role: ROLE_PAD, re: /\b(pad|voice|vox|choir|noise|wind|rain|ambient|drone|ghost|foul|suns|birds|crow|atmosph|air)/i },
{ role: ROLE_LEAD, re: /\b(lead|synth_|gameboy|arpy|piano|keys|nujazz_keys|nujazz_guitar|fpiano|qstab|pluck|melody|guitar)/i }
];
// Per-orbit fallback when sound is unknown or doesn't match any pattern.
// Mirrors ParVagues channel convention: low orbits = rhythm/bass, mids =
// melodic, highs = atmospheric.
const DEFAULT_BY_ORBIT = {
1: ROLE_RHYTHM,
2: ROLE_RHYTHM,
3: ROLE_RHYTHM,
4: ROLE_BASS,
5: ROLE_LEAD,
6: ROLE_RHYTHM,
7: ROLE_RHYTHM,
8: ROLE_RHYTHM,
9: ROLE_PAD,
10: ROLE_RISER,
11: ROLE_LEAD,
12: ROLE_LEAD
};
// Returns { role, source, matched } so the HUD tooltip can show *why*.
// source: 'sound' | 'group' | 'orbit-default' | 'none'
// matched: the substring that triggered the match (for sound source)
export function classifyRole(orbit) {
if (orbit && orbit.sound) {
const s = String(orbit.sound).toLowerCase();
for (const { role, re } of PATTERNS) {
const m = s.match(re);
if (m) return { role, source: 'sound', matched: m[0] };
}
}
// gF2 = djfbus 2 in ParVagues convention = bass channel routing.
if (orbit && orbit.uses && orbit.uses.includes('gF2')) {
return { role: ROLE_BASS, source: 'group', matched: 'gF2' };
}
if (orbit && DEFAULT_BY_ORBIT[orbit.n]) {
return { role: DEFAULT_BY_ORBIT[orbit.n], source: 'orbit-default', matched: `d${orbit.n}` };
}
return { role: null, source: 'none', matched: null };
}
'use babel';
// Tidal keyword sets used to resolve what each "^NN" CC reference binds to.
// Order in the union matters: longer/more-specific names should match first,
// so we scan effects-bus before plain effects.
export const EFFECTS_BUS = [
'djfbus', 'hpfbus', 'lpfbus', 'bpfbus',
'octerbus', 'roombus', 'shapebus', 'crushbus',
'delaybus', 'delaytimebus', 'delayfbbus'
];
export const EFFECTS = [
'gain', 'pan', 'lpf', 'hpf', 'bpf', 'crush', 'shape',
'room', 'sz', 'dry', 'octer',
'delay', 'delaytime', 'delayfb',
'speed', 'cut', 'legato', 'attack', 'release', 'sustain'
];
export const PATTERN_MODS = [
'ply', 'slow', 'fast', 'chop', 'slice', 'loopAt', 'iter',
'off', 'every', 'whenmod', 'range', 'struct', 'mask',
'rev', 'jux', 'juxBy', 'superimpose', 'stut', 'echo'
];
export const TRIGGERS = [
'midiOn', 'midiOff', 'sometimesBy', 'someCyclesBy',
'sometimes', 'rarely', 'often', 'always'
];
export const NOTES = ['note', 'n', 'up'];
// File-level let-bindings the user defines at the top of every track.
// When a "^NN" follows one of these directly inside a `let X = …` line,
// the binding belongs to the GROUPS section, not to any single orbit.
export const GROUPS = [
'gMask', 'gMute1', 'gMute2', 'gMute3',
'gM1', 'gM2', 'gM3', 'gF1', 'gF2', 'gF3'
];
// Generic identifier matcher — catches ANY word-like token so user-defined
// helpers (midiG, leslie, lsize, modIndex, gO, …) get a sensible label
// without us having to maintain an exhaustive whitelist.
const ID_RE = /\b([a-zA-Z_][a-zA-Z0-9_]*)\b/g;
// Identifiers that are Haskell/Tidal syntax, not Tidal effect names. When
// they appear as the "nearest" word before a "^NN", we skip them and use
// the next-nearest meaningful identifier instead.
const SKIP_IDS = new Set(['let', 'in', 'do', 'where', 'if', 'then', 'else', 'of', 'fix']);
// Triggers: identifiers where the CC IS the button (state toggle or
// probability source). The "^NN" is directly associated with this id —
// don't backtrack past it to the section head.
// midiOn "^42" ... → keep midiOn (→ on)
// midiOff "^57" (midiOn "^89" ...) → cc57 stays midiOff, cc89 stays midiOn
// sometimesBy "^56" (...) → keep sometimesBy (→ some)
const TRIGGER_IDS = new Set([
'midiOn', 'midiOff',
'sometimesBy', 'someCyclesBy',
'sometimes', 'someCycles',
'juxBy'
]);
// Given the substring of a line BEFORE the "^NN" occurrence, return the
// nearest enclosing identifier (last match that isn't a skip word), or null.
export function nearestKeywordBefore(prefix) {
let last = null;
let m;
ID_RE.lastIndex = 0;
while ((m = ID_RE.exec(prefix)) !== null) {
if (!SKIP_IDS.has(m[1])) last = m[1];
}
return last;
}
// All identifier matches in order. Used by the range-backtrack rule.
function allKeywordsBefore(prefix) {
const out = [];
let m;
ID_RE.lastIndex = 0;
while ((m = ID_RE.exec(prefix)) !== null) {
if (!SKIP_IDS.has(m[1])) out.push({ keyword: m[1], index: m.index });
}
return out;
}
// Resolve the effect label for a "^NN" found at ccIndex on `line`.
// Smarter than classifyBinding: handles the `# <effect>[bus N] (range … "^NN")`
// idiom by stepping back from `range` to the enclosing #/$ head.
// Resolve the effect label for a "^NN" found at ccIndex on `line`.
//
// The Tidal idiom is `# <effect>[bus N] (<wrapper> … "^NN")` or
// `$ <trigger> "^NN" (...)`. In both shapes, the meaningful effect is the
// FIRST identifier after the nearest #/$ section head — regardless of
// what wrapping the value passes through (range, slow, fast, a custom
// helper, etc.). That single rule subsumes the old range-only backtrack.
//
// Examples handled:
// # crush 0.5 "^22" → crush
// # crush (range 16 4 "^53") → crush
// # octerbus 72 (range 0 1 "^35") → octer
// # lsize (range 0 8 "^16") → lsize
// # foo (someWrapper "^22") → foo
// # n "^29" → n
// $ midiOn "^42" (...) → on (or 'mute' if line has mask "f*N")
// $ sometimesBy "^56" (...) → some
export function classifyEffectForCC(line, ccIndex) {
const prefix = line.slice(0, ccIndex);
// Step 1: find the identifier IMMEDIATELY preceding the "^NN".
const immediate = nearestKeywordBefore(prefix);
// Step 2: if it's a trigger (the CC IS the button/probability source),
// keep it as-is — don't backtrack past it.
// midiOff "^57" (midiOn "^89" ...) → cc89 stays midiOn, cc57 stays midiOff
if (immediate && TRIGGER_IDS.has(immediate)) {
return classifyBinding(immediate, line);
}
// Step 3: otherwise the CC is being shaped (range, slow, fast, lsize,
// someUserHelper, …). The effect we care about is the first identifier
// after the nearest #/$ section head.
// # foo (range 0.2 0.8 "^22") → foo
// # crush (someWrapper "^22") → crush
// # octerbus N (range … "^35") → octer
const hashIdx = prefix.lastIndexOf('#');
const dollarIdx = prefix.lastIndexOf('$');
const sepIdx = Math.max(hashIdx, dollarIdx);
if (sepIdx >= 0) {
const rest = prefix.slice(sepIdx + 1);
const idm = rest.match(/([a-zA-Z_][a-zA-Z0-9_]*)/);
if (idm && !SKIP_IDS.has(idm[1])) {
return classifyBinding(idm[1], line);
}
}
// Final fallback: just use the immediate identifier (covers
// `(midiOn "^X" ...)` deep inside a let body with no visible #/$).
return classifyBinding(immediate, line);
}
// Short, perf-readable effect labels — we trade verbosity for at-a-glance
// scannability during livecoding. Verbose names still appear in tooltips.
const SHORT_LABELS = {
midiOn: 'on',
midiOff: 'off',
sometimesBy: 'some',
someCyclesBy: 'cyc',
sometimes: 'some',
someCycles: 'cyc',
superimpose: 'sup',
loopAt: 'loop',
whenmod: 'when',
delaytime: 'dt',
delayfb: 'dfb',
attack: 'att',
release: 'rel',
sustain: 'sus'
};
// Map the matched keyword onto a short human-readable effect label.
// Strips "bus" suffixes; classifies midiOn-with-mask as 'mute'.
export function classifyBinding(keyword, lineText) {
if (!keyword) return '?';
if (keyword.endsWith('bus')) return keyword.slice(0, -3);
if ((keyword === 'midiOn' || keyword === 'midiOff') && /mask\s+"f\*\d+"/.test(lineText)) {
return 'mute';
}
return SHORT_LABELS[keyword] || keyword;
}
{
"menu": [
{
"label": "Packages",
"submenu": [
{
"label": "ParVagues HUD",
"submenu": [
{ "label": "Toggle HUD", "command": "parvagues-hud:toggle" },
{ "label": "Toggle GROUPS row", "command": "parvagues-hud:toggle-groups" },
{ "label": "Refresh now", "command": "parvagues-hud:refresh" }
]
}
]
}
],
"context-menu": {
"atom-text-editor[data-grammar='source tidalcycles']": [
{ "label": "ParVagues HUD: refresh", "command": "parvagues-hud:refresh" }
]
}
}
{
"name": "pulsar-parvagues-hud",
"version": "0.1.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "pulsar-parvagues-hud",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"less": "^4.6.4"
},
"engines": {
"atom": ">=1.0.0 <2.0.0"
}
},
"node_modules/copy-anything": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz",
"integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==",
"dev": true,
"dependencies": {
"is-what": "^4.1.8"
},
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/errno": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz",
"integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==",
"dev": true,
"optional": true,
"dependencies": {
"prr": "~1.0.1"
},
"bin": {
"errno": "cli.js"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"optional": true
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/image-size": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz",
"integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==",
"dev": true,
"optional": true,
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-what": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz",
"integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==",
"dev": true,
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/less": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/less/-/less-4.6.4.tgz",
"integrity": "sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==",
"dev": true,
"dependencies": {
"copy-anything": "^3.0.5",
"parse-node-version": "^1.0.1"
},
"bin": {
"lessc": "bin/lessc"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"errno": "^0.1.1",
"graceful-fs": "^4.1.2",
"image-size": "~0.5.0",
"make-dir": "^2.1.0",
"mime": "^1.4.1",
"needle": "^3.1.0",
"source-map": "~0.6.0"
}
},
"node_modules/make-dir": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
"integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
"dev": true,
"optional": true,
"dependencies": {
"pify": "^4.0.1",
"semver": "^5.6.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"dev": true,
"optional": true,
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/needle": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/needle/-/needle-3.5.0.tgz",
"integrity": "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==",
"dev": true,
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.3",
"sax": "^1.2.4"
},
"bin": {
"needle": "bin/needle"
},
"engines": {
"node": ">= 4.4.x"
}
},
"node_modules/parse-node-version": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz",
"integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==",
"dev": true,
"engines": {
"node": ">= 0.10"
}
},
"node_modules/pify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
"dev": true,
"optional": true,
"engines": {
"node": ">=6"
}
},
"node_modules/prr": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
"integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==",
"dev": true,
"optional": true
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"optional": true
},
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"dev": true,
"optional": true,
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true,
"optional": true,
"bin": {
"semver": "bin/semver"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"optional": true,
"engines": {
"node": ">=0.10.0"
}
}
},
"dependencies": {
"copy-anything": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz",
"integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==",
"dev": true,
"requires": {
"is-what": "^4.1.8"
}
},
"errno": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz",
"integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==",
"dev": true,
"optional": true,
"requires": {
"prr": "~1.0.1"
}
},
"graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"optional": true
},
"iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"optional": true,
"requires": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
}
},
"image-size": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz",
"integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==",
"dev": true,
"optional": true
},
"is-what": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz",
"integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==",
"dev": true
},
"less": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/less/-/less-4.6.4.tgz",
"integrity": "sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==",
"dev": true,
"requires": {
"copy-anything": "^3.0.5",
"errno": "^0.1.1",
"graceful-fs": "^4.1.2",
"image-size": "~0.5.0",
"make-dir": "^2.1.0",
"mime": "^1.4.1",
"needle": "^3.1.0",
"parse-node-version": "^1.0.1",
"source-map": "~0.6.0"
}
},
"make-dir": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
"integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
"dev": true,
"optional": true,
"requires": {
"pify": "^4.0.1",
"semver": "^5.6.0"
}
},
"mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"dev": true,
"optional": true
},
"needle": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/needle/-/needle-3.5.0.tgz",
"integrity": "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==",
"dev": true,
"optional": true,
"requires": {
"iconv-lite": "^0.6.3",
"sax": "^1.2.4"
}
},
"parse-node-version": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz",
"integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==",
"dev": true
},
"pify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
"dev": true,
"optional": true
},
"prr": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
"integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==",
"dev": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"optional": true
},
"sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"dev": true,
"optional": true
},
"semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true,
"optional": true
},
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"optional": true
}
}
}
{
"name": "pulsar-parvagues-hud",
"main": "./lib/main",
"version": "0.1.0",
"description": "Track-aware topbar HUD for Tidal livecoding: shows active orbits, sample/synth, and Novation LaunchControl XL binding for the active .tidal file.",
"keywords": [
"tidal",
"tidalcycles",
"livecoding",
"parvagues",
"launchcontrol",
"midi"
],
"repository": "https://github.com/parvagues/pulsar-parvagues-hud",
"license": "MIT",
"engines": {
"atom": ">=1.0.0 <2.0.0"
},
"activationHooks": [
"core:loaded-shell-environment"
],
"configSchema": {
"showGroups": {
"type": "boolean",
"default": true,
"title": "Show GROUPS row",
"description": "Display file-level group bindings (gMask, gM1-3, gF1-3) in a dedicated row above the per-orbit chips."
},
"filenameGlob": {
"type": "string",
"default": ".tidal",
"title": "Active file extension",
"description": "Only show the HUD when the active file ends with this string."
}
},
"devDependencies": {
"less": "^4.6.4"
},
"scripts": {
"lint:less": "node tools/lint-less.cjs",
"test:parser": "node spec/smoke.cjs",
"lint": "./tools/lint.sh"
}
}
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
function loadAsCJS(filepath, depExports = {}) {
let src = fs.readFileSync(filepath, 'utf8');
src = src.replace(/^'use babel';\s*$/gm, '');
src = src.replace(/^import\s+\{([^}]+)\}\s+from\s+['"][^'"]+['"];?/gm,
(_, names) => names.split(',').map(n => `const ${n.trim()} = __DEPS__.${n.trim()};`).join('\n')
);
const exportNames = [];
src = src.replace(/^export\s+function\s+(\w+)/gm, (_, n) => { exportNames.push(n); return `function ${n}`; });
src = src.replace(/^export\s+const\s+(\w+)/gm, (_, n) => { exportNames.push(n); return `const ${n}`; });
const wrapper = `(function(__DEPS__){\n${src}\nreturn { ${exportNames.join(', ')} };\n})`;
return eval(wrapper)(depExports);
}
const libdir = path.join(__dirname, '..', 'lib');
const lcxl = loadAsCJS(path.join(libdir, 'lcxl-map.js'));
const kw = loadAsCJS(path.join(libdir, 'tidal-keywords.js'));
const parser = loadAsCJS(path.join(libdir, 'parser.js'), { ...lcxl, ...kw });
const file = '/home/pln/Work/Sound/Tidal/live/midi/nova/ambient/quand_on_decolle.tidal';
const text = fs.readFileSync(file, 'utf8');
const result = parser.parse(text);
console.log(`orbits=${result.orbits.length} groups=${Object.keys(result.groups).length} errors=${result.errors.length}`);
if (result.errors.length) console.log('errors:', result.errors);
console.log('\nGROUPS:');
for (const [n, g] of Object.entries(result.groups)) {
console.log(` ${n}: cc${g.cc}${g.control.label}${g.effect}`);
}
console.log('\nORBITS:');
for (const o of result.orbits) {
const name = o.name ? ` [${o.name}]` : '';
console.log(` d${o.n}${name} sound="${o.sound}"`);
for (const b of o.bindings) {
console.log(` cc${b.cc}${b.control.label}${b.effect}`);
}
}
#!/usr/bin/env node
// Standalone smoke test — no Pulsar / no Babel needed.
// Loads lib/* by stripping ESM syntax with a simple regex transform.
const fs = require('fs');
const path = require('path');
function loadAsCJS(filepath, depExports = {}) {
let src = fs.readFileSync(filepath, 'utf8');
src = src.replace(/^'use babel';\s*$/gm, '');
src = src.replace(/^import\s+\{([^}]+)\}\s+from\s+['"][^'"]+['"];?/gm,
(_, names) => names.split(',').map(n => `const ${n.trim()} = __DEPS__.${n.trim()};`).join('\n')
);
const exportNames = [];
src = src.replace(/^export\s+function\s+(\w+)/gm, (_, n) => { exportNames.push(n); return `function ${n}`; });
src = src.replace(/^export\s+const\s+(\w+)/gm, (_, n) => { exportNames.push(n); return `const ${n}`; });
const wrapper = `(function(__DEPS__){\n${src}\nreturn { ${exportNames.join(', ')} };\n})`;
return eval(wrapper)(depExports);
}
const libdir = path.join(__dirname, '..', 'lib');
const lcxl = loadAsCJS(path.join(libdir, 'lcxl-map.js'));
const kw = loadAsCJS(path.join(libdir, 'tidal-keywords.js'));
const role = loadAsCJS(path.join(libdir, 'role-classifier.js'));
const parser = loadAsCJS(path.join(libdir, 'parser.js'), { ...lcxl, ...kw, ...role });
function assert(cond, msg) {
if (!cond) { console.error(' ✘ ' + msg); process.exitCode = 1; }
else { console.log(' ✓ ' + msg); }
}
function run(label, filepath, expects) {
console.log(`\n[${label}] ${filepath}`);
if (!fs.existsSync(filepath)) { console.log(' (file missing, skipping)'); return; }
const text = fs.readFileSync(filepath, 'utf8');
let result;
const t0 = Date.now();
try {
result = parser.parse(text);
} catch (e) {
console.error(' ✘ parser threw: ' + e.message);
process.exitCode = 1;
return;
}
const dt = Date.now() - t0;
console.log(` parsed in ${dt}ms (orbits=${result.orbits.length} groups=${Object.keys(result.groups).length} errors=${result.errors.length})`);
assert(dt < 50, `parse < 50ms`);
assert(result.errors.length === 0, `no parse errors ${result.errors.length ? '— ' + result.errors.join(' | ') : ''}`);
if (expects) {
for (const e of expects) {
const orbit = result.orbits.find(o => o.n === e.n);
assert(orbit, `orbit d${e.n} found`);
if (!orbit) continue;
if (e.soundContains) assert(
(orbit.sound || '').includes(e.soundContains),
`d${e.n}.sound contains "${e.soundContains}" (got "${orbit.sound}")`
);
if (e.bindings) {
for (const b of e.bindings) {
const found = orbit.bindings.find(x => x.control.label === b.label && x.effect === b.effect);
assert(found, `d${e.n} has binding ${b.label}:${b.effect} (have: ${orbit.bindings.map(x => x.control.label + ':' + x.effect).join(',')})`);
}
}
}
}
return result;
}
console.log('=== pulsar-parvagues-hud parser smoke ===');
run('bullet_train', '/home/pln/Work/Sound/Tidal/bullet_train.tidal', [
{ n: 1, soundContains: 'jazz' }, // # "jazz" wins over bare "k k k k*<1 2>"
{ n: 4, soundContains: 'bassWarsaw' },
{ n: 8, soundContains: 'jungle_breaks' }
]);
run('techno_orage', '/home/pln/Work/Sound/Tidal/live/midi/nova/techno/techno_orage.tidal', [
{ n: 3, soundContains: 'orage' }, // not "<1 2 3>" — synth name wins
{ n: 6, soundContains: 'clap' }, // d6 special (B3) — should still be picked up
{ n: 10, soundContains: 'risers' }
]);
const ns = run('nouveau_soleil', '/home/pln/Work/Sound/Tidal/live/midi/nova/dnb/nouveau_soleil.tidal', [
{ n: 4, soundContains: 'bass_gameboy' },
{ n: 5, soundContains: 'synth_gameboy' },
{ n: 7, soundContains: 'moogBass', bindings: [{ label: 'B7', effect: 'octer' }, { label: 'C7', effect: 'room' }] },
{ n: 8, soundContains: 'breaks165' }
]);
// Dump a compact view of nouveau_soleil for human eyeballing.
if (ns) {
console.log('\n--- nouveau_soleil dump ---');
console.log('GROUPS: ' + Object.entries(ns.groups).map(([k, v]) => `${k}:${v.control.label}:${v.effect}`).join(' · '));
for (const o of ns.orbits) {
const sound = o.sound ? `"${o.sound.slice(0,28)}"` : '(no sound)';
const binds = o.bindings.map(b => `${b.control.label}:${b.effect}`).join(' · ');
const name = o.name ? `—${o.name}— ` : '';
const role = o.role ? ` {${o.role}/${o.roleSource}}` : '';
console.log(` d${o.n} ${name}${sound}${role} [${binds}]`);
}
}
console.log('\n=== smoke complete ===');
@import "ui-variables";
@import "syntax-variables";
// Role-based color palette — five buckets driven by sample-name content
// + group routing + orbit-number convention (see lib/role-classifier.js).
// Scanning becomes "what is it doing?" not "which orbit number?".
@role-rhythm: hsl( 0, 70%, 60%); // warm red — pulse / energy
@role-bass: hsl(265, 55%, 55%); // deep purple — depth
@role-lead: hsl( 48, 80%, 58%); // bright yellow — melodic foreground
@role-pad: hsl(180, 45%, 55%); // soft teal — atmospheric
@role-riser: hsl(325, 70%, 62%); // magenta — transition / special
@role-none: hsl( 0, 0%, 50%); // gray — unclassified
// Legacy per-orbit hues retained as fallback only — used when an orbit
// has no role (sound unknown AND no convention default). Most renders
// use role classes; orbit hues kick in only for orphans / d13+.
@orbit-color-1: hsl( 0, 70%, 60%);
@orbit-color-2: hsl( 30, 75%, 60%);
@orbit-color-3: hsl( 55, 70%, 55%);
@orbit-color-4: hsl(155, 50%, 50%);
@orbit-color-5: hsl(195, 65%, 55%);
@orbit-color-6: hsl(220, 70%, 65%);
@orbit-color-7: hsl(275, 55%, 60%);
@orbit-color-8: hsl(330, 70%, 60%);
@orbit-color-9: hsl( 85, 55%, 50%);
@orbit-color-10: hsl(175, 40%, 50%);
@orbit-color-11: hsl( 15, 40%, 55%);
@orbit-color-12: hsl(255, 40%, 60%);
.parvagues-hud {
padding: 4px 8px 6px 8px;
background: lighten(@base-background-color, 2%);
border-bottom: 1px solid @base-border-color;
font-family: @font-family;
font-size: 10.5px;
line-height: 1.25;
color: @text-color;
display: flex;
flex-direction: column;
gap: 4px;
}
// 8-lane × 6-row CSS grid (A B C D E F). Every orbit label is embedded
// in its convention cell — no separate header bands. D row is the tallest
// because that's where the faders + d1..d8 labels live.
.pv-grid {
display: grid;
grid-template-columns: 18px repeat(8, minmax(0, 1fr));
grid-template-rows: 18px 18px 18px 26px 16px 16px;
gap: 1px;
background: @base-border-color;
border: 1px solid @base-border-color;
border-radius: 3px;
overflow: hidden;
}
// Tint the three control zones to mirror the physical device layout:
// knob-block (A/B/C) — neutral
// fader-block (D) — slightly brighter, taller cells, vertical bar accent
// button-block (E/F) — slightly darker
//
// Targeting via :nth-child since the grid is flat — header is first 9 cells,
// then 9 cells per row × 6 rows.
// header: children 1..9
// row A: 10..18
// row B: 19..27
// row C: 28..36
// row D: 37..45 ← FADER ROW
// row E: 46..54
// row F: 55..63
.pv-grid > .pv-row-label-A,
.pv-grid > .pv-row-label-B,
.pv-grid > .pv-row-label-C {
background: lighten(@base-background-color, 3%);
}
.pv-grid > .pv-row-label-D {
background: lighten(@base-background-color, 6%);
color: @text-color-highlight;
border-top: 1px solid fade(@text-color-subtle, 30%);
border-bottom: 1px solid fade(@text-color-subtle, 30%);
}
.pv-grid > .pv-row-label-E,
.pv-grid > .pv-row-label-F {
background: darken(@base-background-color, 2%);
}
// Cells in the fader row: brighter background + a fader-bar visual hint.
.pv-grid > .pv-cell[data-row="D"] {
background: lighten(@base-background-color, 5%);
border-top: 1px solid fade(@text-color-subtle, 25%);
border-bottom: 1px solid fade(@text-color-subtle, 25%);
position: relative;
}
// Bound faders get a vertical accent bar on the left — mimics the 60mm strip.
.pv-grid > .pv-cell[data-row="D"]:not(.pv-cell-empty)::before {
content: "";
position: absolute;
left: 2px;
top: 3px;
bottom: 3px;
width: 3px;
border-radius: 1px;
background: currentColor;
opacity: 0.45;
}
.pv-grid > .pv-cell[data-row="D"]:not(.pv-cell-empty) {
padding-left: 9px;
}
// Cells in the button rows: darker background.
.pv-grid > .pv-cell[data-row="E"],
.pv-grid > .pv-cell[data-row="F"] {
background: darken(@base-background-color, 1%);
}
// Row labels (left column: blank header, then A/B/C/D/E/F)
.pv-row-label {
display: flex;
align-items: center;
justify-content: center;
background: lighten(@base-background-color, 3%);
color: @text-color-subtle;
font-weight: 700;
font-size: 10px;
}
// Per-orbit chip used only in the orphans footer (and as a fallback shape).
.pv-orbit-chip {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 4px;
border-radius: 2px;
background: fade(@text-color, 8%);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
min-width: 0;
}
.pv-orbit-chip .pv-orbit-n {
color: @text-color-highlight;
font-weight: 700;
font-size: 10px;
}
// Master placeholder in the D1 cell (Ardour master volume — not a Tidal orbit)
.pv-cell-master {
background: fade(@text-color-subtle, 12%);
}
.pv-master-label {
color: @text-color-subtle;
font-weight: 700;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
// Inline orbit chip — embedded directly in a body cell (e.g. d6 at B3).
// Distinct from binding pills: shows orbit number + (truncated) sound.
.pv-inline-orbit {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 0 4px;
border-radius: 2px;
background: fade(@text-color, 10%);
font-size: 10px;
font-weight: 600;
margin-right: 3px;
}
.pv-inline-orbit .pv-orbit-n {
color: @text-color-highlight;
font-weight: 700;
}
.pv-inline-orbit .pv-orbit-sound {
color: @text-color-success;
font-weight: 500;
font-style: italic;
}
.pv-orbit-name {
color: @text-color-info;
font-style: italic;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
}
.pv-orbit-sound {
color: @text-color-success;
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 auto;
min-width: 0;
}
// Each cell in the grid body
.pv-cell {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 1px 4px;
gap: 3px;
background: lighten(@base-background-color, 4%);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
}
.pv-cell-empty .pv-cell-dot {
color: fade(@text-color-subtle, 40%);
}
.pv-effect {
display: inline-flex;
align-items: center;
border-radius: 2px;
font-size: 10px;
background: fade(@text-color, 8%);
color: @text-color;
cursor: help;
max-width: 100%;
overflow: hidden;
min-width: 0;
}
.pv-effect-badge {
padding: 0 4px;
font-weight: 700;
background: fade(@text-color, 14%);
white-space: nowrap;
flex-shrink: 0;
}
.pv-effect-content {
padding: 0 4px;
color: @text-color-subtle;
font-weight: 500;
font-family: @font-family;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.pv-effect.pv-from-group {
background: fade(@text-color-subtle, 18%);
color: @text-color-subtle;
font-style: italic;
}
.pv-effect.pv-off-lane {
opacity: 0.75;
font-style: italic;
}
// Convention placement (orbit has no explicit "^NN" — placed by default rule)
.pv-effect.pv-from-convention {
opacity: 0.55;
font-style: italic;
font-weight: 500;
background: transparent;
border: 1px dashed fade(@text-color-subtle, 60%);
}
// Apply per-orbit color tint to header bands, inline chips, and binding pills.
// Generated by mixin so all 12 orbits get the same treatment.
.orbit-tint(@n, @color) {
// Top header (row-A orbits) — orbit color as bottom accent
.pv-col-head-top.pv-col-owned-d@{n} {
border-top: 2px solid @color;
}
// Bottom header (row-D orbits) — orbit color as top accent
.pv-col-head-bot.pv-col-owned-d@{n} {
border-bottom: 2px solid @color;
}
// Orbit chip inside a header
.pv-orbit-chip.pv-orbit-d@{n} {
border-left: 3px solid @color;
}
// Inline chip (e.g. d6 at B3)
.pv-inline-orbit.pv-orbit-d@{n} {
border-left: 3px solid @color;
}
// Binding pill colored by which orbit it belongs to
.pv-effect.pv-orbit-d@{n} {
background: fade(@text-color, 12%);
border-left: 2px solid @color;
}
.pv-orphan-chip.pv-orbit-d@{n} {
border-left: 3px solid @color;
}
}
.orbit-tint(1, @orbit-color-1);
.orbit-tint(2, @orbit-color-2);
.orbit-tint(3, @orbit-color-3);
.orbit-tint(4, @orbit-color-4);
.orbit-tint(5, @orbit-color-5);
.orbit-tint(6, @orbit-color-6);
.orbit-tint(7, @orbit-color-7);
.orbit-tint(8, @orbit-color-8);
.orbit-tint(9, @orbit-color-9);
.orbit-tint(10, @orbit-color-10);
.orbit-tint(11, @orbit-color-11);
.orbit-tint(12, @orbit-color-12);
// Role tint — takes precedence over per-orbit hue. Applied to inline
// chips, binding pills, mini digits, and orphan chips. Higher CSS
// specificity than .orbit-tint via the .pv-role-X class.
.role-tint(@role, @color) {
.pv-orbit-chip.pv-role-@{role},
.pv-inline-orbit.pv-role-@{role},
.pv-orphan-chip.pv-role-@{role} {
border-left: 3px solid @color;
}
.pv-effect.pv-role-@{role} {
background: fade(@color, 18%);
border-left: 2px solid @color;
}
// Single-digit number inside the compact group-affect list ("d 2 3 8")
.pv-orbit-digit.pv-role-@{role} {
color: @color;
}
}
.role-tint(rhythm, @role-rhythm);
.role-tint(bass, @role-bass);
.role-tint(lead, @role-lead);
.role-tint(pad, @role-pad);
.role-tint(riser, @role-riser);
// Compact group-affect list: `d 2 3 8` where `d` is gray and each digit
// is role-tinted. Replaces the older per-digit mini-chips (less ink,
// faster to scan).
.pv-group-users {
display: inline-flex;
align-items: baseline;
gap: 1px;
padding-left: 2px;
}
.pv-orbit-list-d {
color: @text-color-subtle;
font-weight: 500;
font-size: 9px;
margin-right: 1px;
}
.pv-orbit-digit {
font-weight: 700;
font-size: 10px;
color: @text-color-highlight;
padding: 0 1px;
}
.pv-orbit-list-sep {
color: @text-color-subtle;
font-size: 9px;
opacity: 0.6;
}
// Orphans (orbits with no LCXL CCs) — small footer line
.pv-orphans-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 10px;
}
.pv-section-label {
color: @text-color-subtle;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
font-size: 9px;
}
.pv-orphan-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 6px;
background: lighten(@base-background-color, 4%);
border-radius: 2px;
}
.pv-orphan-chip .pv-orbit-n {
color: @text-color-highlight;
font-weight: 700;
}
.pv-dim {
color: @text-color-subtle;
font-style: italic;
}
.pv-error-badge {
align-self: flex-end;
padding: 1px 6px;
border-radius: 2px;
background: fade(@background-color-warning, 30%);
color: @text-color-warning;
font-size: 10px;
cursor: help;
}
#!/usr/bin/env node
// LESS validator. Uses the less JS API (CJS, Node-16-friendly) so we
// don't depend on the lessc bin (ESM, requires Node ≥18).
//
// Usage: node tools/lint-less.cjs [files...]
// defaults to styles/*.less when no args.
const fs = require('fs');
const path = require('path');
const less = require('less');
const root = path.resolve(__dirname, '..');
function findLessFiles() {
const dir = path.join(root, 'styles');
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir)
.filter(f => f.endsWith('.less'))
.map(f => path.join(dir, f));
}
async function check(file) {
const rel = path.relative(root, file);
const src = fs.readFileSync(file, 'utf8');
// Stub the Atom ui-variables / syntax-variables imports — they only
// exist inside Pulsar at runtime. We don't need their values, we just
// need the LESS parser to accept the file.
const stub = `
@ui-size: 12px;
@base-background-color: #2b2b2b;
@base-border-color: #444;
@text-color: #ccc;
@text-color-subtle: #888;
@text-color-highlight: #fff;
@text-color-info: #6cf;
@text-color-success: #6c6;
@text-color-warning: #fc6;
@background-color-warning: #c80;
@font-family: monospace;
`;
// Drop the @import lines that pull Atom-only files.
const stripped = src.replace(/^@import\s+["'](ui-variables|syntax-variables)["'];?\s*$/gm, '');
const input = stub + '\n' + stripped;
try {
await less.render(input, { math: 'strict', filename: file });
console.log(` \x1b[32m✓\x1b[0m ${rel}`);
return true;
} catch (e) {
const line = e.line || '?';
const col = e.column || '?';
console.log(` \x1b[31m✘\x1b[0m ${rel}`);
console.log(` \x1b[31m${e.type || 'Error'}\x1b[0m at ${line}:${col}${e.message || e}`);
if (e.extract) {
const ln = e.line - 1;
e.extract.forEach((l, i) => {
if (l == null) return;
const marker = (i === 1) ? '\x1b[31m▶\x1b[0m' : ' ';
console.log(` ${marker} ${(ln + i).toString().padStart(4)} | ${l}`);
});
}
return false;
}
}
(async () => {
const args = process.argv.slice(2);
const files = args.length ? args : findLessFiles();
if (!files.length) { console.log('no .less files'); process.exit(0); }
console.log(`== LESS lint (${files.length} file${files.length > 1 ? 's' : ''}) ==`);
let allOk = true;
for (const f of files) {
const ok = await check(f);
if (!ok) allOk = false;
}
process.exit(allOk ? 0 : 1);
})();
#!/usr/bin/env bash
# Pre-commit-style check: compile LESS + run parser smoke.
# Fails non-zero if anything is broken. Run from package root.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if [ ! -d "node_modules/less" ]; then
echo "less not installed — run: npm install" >&2
exit 2
fi
node tools/lint-less.cjs
echo
echo "== Parser smoke =="
node spec/smoke.cjs
#!/usr/bin/env node
// Headless CLI wrapper around lib/parser.parse() — runs the HUD parser
// against any .tidal file and prints JSON to stdout. Reuses the babel-free
// ESM-strip loader from spec/smoke.cjs.
//
// Usage:
// node tools/parse-cli.cjs path/to/track.tidal
// Output: JSON with {file, orbits[], groups, errors}.
const fs = require('fs');
const path = require('path');
function loadAsCJS(filepath, depExports = {}) {
let src = fs.readFileSync(filepath, 'utf8');
src = src.replace(/^'use babel';\s*$/gm, '');
src = src.replace(/^import\s+\{([^}]+)\}\s+from\s+['"][^'"]+['"];?/gm,
(_, names) => names.split(',').map(n => `const ${n.trim()} = __DEPS__.${n.trim()};`).join('\n')
);
const exportNames = [];
src = src.replace(/^export\s+function\s+(\w+)/gm,
(_, n) => { exportNames.push(n); return `function ${n}`; });
src = src.replace(/^export\s+const\s+(\w+)/gm,
(_, n) => { exportNames.push(n); return `const ${n}`; });
const wrapper = `(function(__DEPS__){\n${src}\nreturn { ${exportNames.join(', ')} };\n})`;
return eval(wrapper)(depExports);
}
function main() {
const arg = process.argv[2];
if (!arg) {
console.error('usage: parse-cli.cjs <path/to/track.tidal>');
process.exit(2);
}
const file = path.resolve(arg);
if (!fs.existsSync(file)) {
console.error(`not found: ${file}`);
process.exit(2);
}
const libdir = path.join(__dirname, '..', 'lib');
const lcxl = loadAsCJS(path.join(libdir, 'lcxl-map.js'));
const kw = loadAsCJS(path.join(libdir, 'tidal-keywords.js'));
const role = loadAsCJS(path.join(libdir, 'role-classifier.js'));
const parser = loadAsCJS(path.join(libdir, 'parser.js'),
{ ...lcxl, ...kw, ...role });
const text = fs.readFileSync(file, 'utf8');
const result = parser.parse(text);
// Bolt on a header-comment extract — useful for viz metadata, not part
// of the HUD parser proper.
const headerComments = [];
for (const line of text.split(/\r?\n/)) {
const m = line.match(/^\s*--\s?(.+?)\s*$/);
if (m) {
headerComments.push(m[1]);
if (headerComments.length >= 12) break;
} else if (line.trim() && !line.startsWith('--')) {
break;
}
}
console.log(JSON.stringify({
file: file,
header_comments: headerComments,
groups: result.groups,
orbits: result.orbits,
errors: result.errors,
}, null, 2));
}
main();
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment