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';
// 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",
"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 ===');
#!/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