notemap 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +4 -0
- package/dist/mapsManager.js +67 -0
- package/dist/midiEngine.js +86 -0
- package/dist/notes.js +12 -0
- package/dist/types.js +2 -0
- package/dist/ui.js +474 -0
- package/package.json +35 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.MapsManager = void 0;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const os = __importStar(require("os"));
|
|
40
|
+
const MAPS_DIR = path.join(os.homedir(), '.config', 'midimapper', 'maps');
|
|
41
|
+
class MapsManager {
|
|
42
|
+
constructor() {
|
|
43
|
+
fs.mkdirSync(MAPS_DIR, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
list() {
|
|
46
|
+
return fs
|
|
47
|
+
.readdirSync(MAPS_DIR)
|
|
48
|
+
.filter(f => f.endsWith('.json'))
|
|
49
|
+
.map(f => f.replace(/\.json$/, ''))
|
|
50
|
+
.sort();
|
|
51
|
+
}
|
|
52
|
+
load(name) {
|
|
53
|
+
const file = path.join(MAPS_DIR, `${name}.json`);
|
|
54
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
55
|
+
}
|
|
56
|
+
save(map) {
|
|
57
|
+
const file = path.join(MAPS_DIR, `${map.name}.json`);
|
|
58
|
+
fs.writeFileSync(file, JSON.stringify(map, null, 2), 'utf-8');
|
|
59
|
+
}
|
|
60
|
+
exists(name) {
|
|
61
|
+
return fs.existsSync(path.join(MAPS_DIR, `${name}.json`));
|
|
62
|
+
}
|
|
63
|
+
get mapsDir() {
|
|
64
|
+
return MAPS_DIR;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
exports.MapsManager = MapsManager;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MidiEngine = void 0;
|
|
4
|
+
const midi_1 = require("midi");
|
|
5
|
+
class MidiEngine {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.input = null;
|
|
8
|
+
this.output = null;
|
|
9
|
+
}
|
|
10
|
+
getInputDevices() {
|
|
11
|
+
const tmp = new midi_1.Input();
|
|
12
|
+
const count = tmp.getPortCount();
|
|
13
|
+
const names = [];
|
|
14
|
+
for (let i = 0; i < count; i++) {
|
|
15
|
+
names.push(tmp.getPortName(i));
|
|
16
|
+
}
|
|
17
|
+
return names;
|
|
18
|
+
}
|
|
19
|
+
/** Open a device port and forward note-on events to the callback. */
|
|
20
|
+
startMapping(deviceIndex, onNoteOn) {
|
|
21
|
+
this.close();
|
|
22
|
+
this.input = new midi_1.Input();
|
|
23
|
+
this.input.ignoreTypes(false, false, true);
|
|
24
|
+
this.input.openPort(deviceIndex);
|
|
25
|
+
this.input.on('message', (_dt, message) => {
|
|
26
|
+
const [status, note, velocity] = message;
|
|
27
|
+
if ((status & 0xf0) === 0x90 && velocity > 0) {
|
|
28
|
+
onNoteOn(note);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Open the device and a virtual output port named "MidiMapper".
|
|
34
|
+
* All messages are forwarded; note on/off events have their note
|
|
35
|
+
* numbers remapped according to the provided mappings.
|
|
36
|
+
*/
|
|
37
|
+
startActive(deviceIndex, mappings, onMapped) {
|
|
38
|
+
this.close();
|
|
39
|
+
const noteMap = new Map(mappings.map(m => [m.from, m.to]));
|
|
40
|
+
this.output = new midi_1.Output();
|
|
41
|
+
this.output.openVirtualPort('MidiMapper');
|
|
42
|
+
this.input = new midi_1.Input();
|
|
43
|
+
this.input.ignoreTypes(false, false, true);
|
|
44
|
+
this.input.openPort(deviceIndex);
|
|
45
|
+
this.input.on('message', (_dt, message) => {
|
|
46
|
+
if (!this.output)
|
|
47
|
+
return;
|
|
48
|
+
const [status, data1, data2] = message;
|
|
49
|
+
const type = status & 0xf0;
|
|
50
|
+
if (type === 0x90 || type === 0x80) {
|
|
51
|
+
const mapped = noteMap.get(data1) ?? data1;
|
|
52
|
+
const channel = status & 0x0f;
|
|
53
|
+
const isNoteOn = type === 0x90 && data2 > 0;
|
|
54
|
+
this.output.sendMessage([(type | channel), mapped, data2]);
|
|
55
|
+
if (isNoteOn && noteMap.has(data1) && onMapped) {
|
|
56
|
+
onMapped(data1, mapped);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
this.output.sendMessage(message);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
close() {
|
|
65
|
+
if (this.input) {
|
|
66
|
+
try {
|
|
67
|
+
this.input.removeAllListeners('message');
|
|
68
|
+
this.input.closePort();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// ignore
|
|
72
|
+
}
|
|
73
|
+
this.input = null;
|
|
74
|
+
}
|
|
75
|
+
if (this.output) {
|
|
76
|
+
try {
|
|
77
|
+
this.output.closePort();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// ignore
|
|
81
|
+
}
|
|
82
|
+
this.output = null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
exports.MidiEngine = MidiEngine;
|
package/dist/notes.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.noteName = noteName;
|
|
4
|
+
exports.noteLabel = noteLabel;
|
|
5
|
+
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
|
6
|
+
function noteName(n) {
|
|
7
|
+
const octave = Math.floor(n / 12) - 1;
|
|
8
|
+
return `${NOTE_NAMES[n % 12]}${octave}`;
|
|
9
|
+
}
|
|
10
|
+
function noteLabel(n) {
|
|
11
|
+
return `${noteName(n)} (${n})`;
|
|
12
|
+
}
|
package/dist/types.js
ADDED
package/dist/ui.js
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.startApp = startApp;
|
|
37
|
+
const blessed = __importStar(require("blessed"));
|
|
38
|
+
const midiEngine_1 = require("./midiEngine");
|
|
39
|
+
const mapsManager_1 = require("./mapsManager");
|
|
40
|
+
const notes_1 = require("./notes");
|
|
41
|
+
function startApp() {
|
|
42
|
+
const screen = blessed.screen({
|
|
43
|
+
smartCSR: true,
|
|
44
|
+
title: 'MidiMapper',
|
|
45
|
+
fullUnicode: true,
|
|
46
|
+
forceUnicode: true,
|
|
47
|
+
});
|
|
48
|
+
const engine = new midiEngine_1.MidiEngine();
|
|
49
|
+
const maps = new mapsManager_1.MapsManager();
|
|
50
|
+
// ── App state ────────────────────────────────────────────────────────
|
|
51
|
+
let state = 'DEVICE_SELECT';
|
|
52
|
+
let selectedDevice = -1;
|
|
53
|
+
let selectedDeviceName = '';
|
|
54
|
+
let pendingMappings = [];
|
|
55
|
+
let awaitingFirst = true;
|
|
56
|
+
let firstNote = null;
|
|
57
|
+
let activeMapName = '';
|
|
58
|
+
// ── Persistent chrome ────────────────────────────────────────────────
|
|
59
|
+
const header = blessed.box({
|
|
60
|
+
parent: screen,
|
|
61
|
+
top: 0,
|
|
62
|
+
left: 0,
|
|
63
|
+
width: '100%',
|
|
64
|
+
height: 3,
|
|
65
|
+
tags: true,
|
|
66
|
+
content: ' {bold}MidiMapper{/bold}',
|
|
67
|
+
style: { fg: 'white', bg: 'blue', bold: true },
|
|
68
|
+
border: { type: 'line' },
|
|
69
|
+
});
|
|
70
|
+
// suppress unused warning
|
|
71
|
+
void header;
|
|
72
|
+
const footer = blessed.box({
|
|
73
|
+
parent: screen,
|
|
74
|
+
bottom: 0,
|
|
75
|
+
left: 0,
|
|
76
|
+
width: '100%',
|
|
77
|
+
height: 3,
|
|
78
|
+
tags: true,
|
|
79
|
+
style: { fg: 'white', bg: '#111111' },
|
|
80
|
+
border: { type: 'line' },
|
|
81
|
+
});
|
|
82
|
+
function setFooter(text) {
|
|
83
|
+
footer.setContent(` ${text}`);
|
|
84
|
+
screen.render();
|
|
85
|
+
}
|
|
86
|
+
// ── Content area helpers ─────────────────────────────────────────────
|
|
87
|
+
let content = null;
|
|
88
|
+
function clearContent() {
|
|
89
|
+
if (content) {
|
|
90
|
+
content.destroy();
|
|
91
|
+
content = null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function makeContentBox(label, borderColor) {
|
|
95
|
+
clearContent();
|
|
96
|
+
const box = blessed.box({
|
|
97
|
+
parent: screen,
|
|
98
|
+
top: 3,
|
|
99
|
+
left: 0,
|
|
100
|
+
width: '100%',
|
|
101
|
+
height: '100%-6',
|
|
102
|
+
label: ` ${label} `,
|
|
103
|
+
tags: true,
|
|
104
|
+
border: { type: 'line' },
|
|
105
|
+
style: {
|
|
106
|
+
border: { fg: borderColor },
|
|
107
|
+
label: { fg: borderColor, bold: true },
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
content = box;
|
|
111
|
+
return box;
|
|
112
|
+
}
|
|
113
|
+
// ── Global key handlers (state-aware) ────────────────────────────────
|
|
114
|
+
screen.key(['q', 'C-c'], () => {
|
|
115
|
+
engine.close();
|
|
116
|
+
screen.destroy();
|
|
117
|
+
process.exit(0);
|
|
118
|
+
});
|
|
119
|
+
screen.key('enter', () => {
|
|
120
|
+
if (state === 'MAPPING_MODE')
|
|
121
|
+
finishMapping();
|
|
122
|
+
});
|
|
123
|
+
screen.key('escape', () => {
|
|
124
|
+
if (state === 'MAP_SELECT') {
|
|
125
|
+
engine.close();
|
|
126
|
+
showDeviceSelect();
|
|
127
|
+
}
|
|
128
|
+
else if (state === 'MAPPING_MODE') {
|
|
129
|
+
engine.close();
|
|
130
|
+
showMapSelect();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
screen.key('r', () => {
|
|
134
|
+
if (state === 'ACTIVE') {
|
|
135
|
+
engine.close();
|
|
136
|
+
showMapSelect();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
screen.key('d', () => {
|
|
140
|
+
if (state === 'ACTIVE') {
|
|
141
|
+
engine.close();
|
|
142
|
+
showDeviceSelect();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
// ── Device Select ────────────────────────────────────────────────────
|
|
146
|
+
function showDeviceSelect() {
|
|
147
|
+
state = 'DEVICE_SELECT';
|
|
148
|
+
const devices = engine.getInputDevices();
|
|
149
|
+
if (devices.length === 0) {
|
|
150
|
+
const box = makeContentBox('Select MIDI Input Device', 'cyan');
|
|
151
|
+
box.setContent('\n\n {red-fg}No MIDI input devices found.{/red-fg}\n\n' +
|
|
152
|
+
' Make sure a MIDI device is connected, then press {bold}r{/bold} to refresh.');
|
|
153
|
+
setFooter('r: Refresh | q / Ctrl+C: Quit');
|
|
154
|
+
screen.key('r', showDeviceSelect);
|
|
155
|
+
screen.render();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const list = blessed.list({
|
|
159
|
+
parent: screen,
|
|
160
|
+
top: 3,
|
|
161
|
+
left: 0,
|
|
162
|
+
width: '100%',
|
|
163
|
+
height: '100%-6',
|
|
164
|
+
label: ' Select MIDI Input Device ',
|
|
165
|
+
tags: true,
|
|
166
|
+
keys: true,
|
|
167
|
+
vi: true,
|
|
168
|
+
mouse: true,
|
|
169
|
+
border: { type: 'line' },
|
|
170
|
+
style: {
|
|
171
|
+
border: { fg: 'cyan' },
|
|
172
|
+
selected: { bg: 'blue', fg: 'white', bold: true },
|
|
173
|
+
item: { fg: 'white' },
|
|
174
|
+
},
|
|
175
|
+
items: devices.map((d, i) => ` [${i}] ${d}`),
|
|
176
|
+
});
|
|
177
|
+
content = list;
|
|
178
|
+
list.select(0);
|
|
179
|
+
list.focus();
|
|
180
|
+
setFooter('\u2191\u2193: Navigate | Enter: Select | q: Quit');
|
|
181
|
+
list.on('select', (_item, index) => {
|
|
182
|
+
selectedDevice = index;
|
|
183
|
+
selectedDeviceName = devices[index];
|
|
184
|
+
showMapSelect();
|
|
185
|
+
});
|
|
186
|
+
screen.render();
|
|
187
|
+
}
|
|
188
|
+
// ── Map Select ───────────────────────────────────────────────────────
|
|
189
|
+
function showMapSelect() {
|
|
190
|
+
state = 'MAP_SELECT';
|
|
191
|
+
engine.close();
|
|
192
|
+
const mapNames = maps.list();
|
|
193
|
+
const items = [
|
|
194
|
+
' {green-fg}+ Create New Map{/green-fg}',
|
|
195
|
+
...mapNames.map(n => ` ${n}`),
|
|
196
|
+
];
|
|
197
|
+
const list = blessed.list({
|
|
198
|
+
parent: screen,
|
|
199
|
+
top: 3,
|
|
200
|
+
left: 0,
|
|
201
|
+
width: '100%',
|
|
202
|
+
height: '100%-6',
|
|
203
|
+
label: ` Select Map \u2014 Input: {cyan-fg}${selectedDeviceName}{/cyan-fg} `,
|
|
204
|
+
tags: true,
|
|
205
|
+
keys: true,
|
|
206
|
+
vi: true,
|
|
207
|
+
mouse: true,
|
|
208
|
+
border: { type: 'line' },
|
|
209
|
+
style: {
|
|
210
|
+
border: { fg: 'cyan' },
|
|
211
|
+
selected: { bg: 'blue', fg: 'white', bold: true },
|
|
212
|
+
item: { fg: 'white' },
|
|
213
|
+
},
|
|
214
|
+
items,
|
|
215
|
+
});
|
|
216
|
+
content = list;
|
|
217
|
+
list.select(0);
|
|
218
|
+
list.focus();
|
|
219
|
+
setFooter('\u2191\u2193: Navigate | Enter: Select | Esc: Back | q: Quit');
|
|
220
|
+
list.on('select', (_item, index) => {
|
|
221
|
+
if (index === 0) {
|
|
222
|
+
startMappingMode();
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
const name = mapNames[index - 1];
|
|
226
|
+
const map = maps.load(name);
|
|
227
|
+
startActive(map.name, map.mappings);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
screen.render();
|
|
231
|
+
}
|
|
232
|
+
// ── Mapping Mode ─────────────────────────────────────────────────────
|
|
233
|
+
function startMappingMode() {
|
|
234
|
+
pendingMappings = [];
|
|
235
|
+
awaitingFirst = true;
|
|
236
|
+
firstNote = null;
|
|
237
|
+
showMappingMode();
|
|
238
|
+
}
|
|
239
|
+
// Holds refs so we can update them from MIDI callbacks
|
|
240
|
+
let mappingListEl = null;
|
|
241
|
+
let mappingStatusEl = null;
|
|
242
|
+
function updateMappingDisplay() {
|
|
243
|
+
if (!mappingListEl || !mappingStatusEl)
|
|
244
|
+
return;
|
|
245
|
+
if (pendingMappings.length === 0) {
|
|
246
|
+
mappingListEl.setContent(' {gray-fg}(no mappings yet){/gray-fg}');
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
const lines = pendingMappings.map(m => ` {cyan-fg}${(0, notes_1.noteLabel)(m.from)}{/cyan-fg} \u2192 {green-fg}${(0, notes_1.noteLabel)(m.to)}{/green-fg}`);
|
|
250
|
+
mappingListEl.setContent(lines.join('\n'));
|
|
251
|
+
}
|
|
252
|
+
if (awaitingFirst) {
|
|
253
|
+
const count = pendingMappings.length;
|
|
254
|
+
mappingStatusEl.setContent(`{yellow-fg}\u25cf Press a key on your MIDI device to set the {bold}source{/bold} note...{/yellow-fg}` +
|
|
255
|
+
(count > 0
|
|
256
|
+
? ` {gray-fg}(${count} mapping${count !== 1 ? 's' : ''} so far){/gray-fg}`
|
|
257
|
+
: ''));
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
mappingStatusEl.setContent(`{yellow-fg}Source: {bold}${(0, notes_1.noteLabel)(firstNote)}{/bold} \u2014 Now press the {bold}destination{/bold} key...{/yellow-fg}`);
|
|
261
|
+
}
|
|
262
|
+
screen.render();
|
|
263
|
+
}
|
|
264
|
+
function showMappingMode() {
|
|
265
|
+
state = 'MAPPING_MODE';
|
|
266
|
+
const box = makeContentBox('Create New Map', 'cyan');
|
|
267
|
+
// Instructions at top
|
|
268
|
+
blessed.box({
|
|
269
|
+
parent: box,
|
|
270
|
+
top: 1,
|
|
271
|
+
left: 2,
|
|
272
|
+
width: '100%-4',
|
|
273
|
+
height: 2,
|
|
274
|
+
tags: true,
|
|
275
|
+
content: ' Press a key on your MIDI device to begin mapping.\n' +
|
|
276
|
+
' Each pair of key presses creates one mapping: {cyan-fg}source{/cyan-fg} \u2192 {green-fg}destination{/green-fg}.',
|
|
277
|
+
});
|
|
278
|
+
// Divider
|
|
279
|
+
blessed.line({
|
|
280
|
+
parent: box,
|
|
281
|
+
top: 3,
|
|
282
|
+
left: 1,
|
|
283
|
+
width: '100%-2',
|
|
284
|
+
orientation: 'horizontal',
|
|
285
|
+
style: { fg: '#444444' },
|
|
286
|
+
});
|
|
287
|
+
// Mappings list (scrollable)
|
|
288
|
+
mappingListEl = blessed.box({
|
|
289
|
+
parent: box,
|
|
290
|
+
top: 4,
|
|
291
|
+
left: 2,
|
|
292
|
+
width: '100%-4',
|
|
293
|
+
height: '100%-10',
|
|
294
|
+
scrollable: true,
|
|
295
|
+
alwaysScroll: true,
|
|
296
|
+
tags: true,
|
|
297
|
+
});
|
|
298
|
+
// Status line near bottom
|
|
299
|
+
mappingStatusEl = blessed.box({
|
|
300
|
+
parent: box,
|
|
301
|
+
bottom: 2,
|
|
302
|
+
left: 2,
|
|
303
|
+
width: '100%-4',
|
|
304
|
+
height: 1,
|
|
305
|
+
tags: true,
|
|
306
|
+
});
|
|
307
|
+
updateMappingDisplay();
|
|
308
|
+
setFooter('MIDI keys: Map notes | Enter: Done | Esc: Cancel | q: Quit');
|
|
309
|
+
engine.startMapping(selectedDevice, (note) => {
|
|
310
|
+
if (awaitingFirst) {
|
|
311
|
+
firstNote = note;
|
|
312
|
+
awaitingFirst = false;
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
if (firstNote !== null) {
|
|
316
|
+
pendingMappings.push({ from: firstNote, to: note });
|
|
317
|
+
firstNote = null;
|
|
318
|
+
awaitingFirst = true;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
updateMappingDisplay();
|
|
322
|
+
});
|
|
323
|
+
screen.render();
|
|
324
|
+
}
|
|
325
|
+
function finishMapping() {
|
|
326
|
+
if (!awaitingFirst) {
|
|
327
|
+
// incomplete pair — warn
|
|
328
|
+
if (mappingStatusEl) {
|
|
329
|
+
mappingStatusEl.setContent('{red-fg}Incomplete pair — press the destination key first, then Enter.{/red-fg}');
|
|
330
|
+
screen.render();
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (pendingMappings.length === 0) {
|
|
335
|
+
if (mappingStatusEl) {
|
|
336
|
+
mappingStatusEl.setContent('{red-fg}No mappings yet — press some keys to create mappings first.{/red-fg}');
|
|
337
|
+
screen.render();
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
engine.close();
|
|
342
|
+
showNameInput();
|
|
343
|
+
}
|
|
344
|
+
// ── Name Input ───────────────────────────────────────────────────────
|
|
345
|
+
function showNameInput() {
|
|
346
|
+
state = 'NAME_INPUT';
|
|
347
|
+
const box = makeContentBox('Save Map', 'cyan');
|
|
348
|
+
blessed.box({
|
|
349
|
+
parent: box,
|
|
350
|
+
top: 2,
|
|
351
|
+
left: 4,
|
|
352
|
+
width: '100%-8',
|
|
353
|
+
height: 3,
|
|
354
|
+
tags: true,
|
|
355
|
+
content: ` {bold}${pendingMappings.length}{/bold} mapping${pendingMappings.length !== 1 ? 's' : ''} ready to save.\n\n` +
|
|
356
|
+
' Enter a name for this map:',
|
|
357
|
+
});
|
|
358
|
+
const inputBox = blessed.textbox({
|
|
359
|
+
parent: box,
|
|
360
|
+
top: 6,
|
|
361
|
+
left: 4,
|
|
362
|
+
width: 42,
|
|
363
|
+
height: 3,
|
|
364
|
+
inputOnFocus: true,
|
|
365
|
+
keys: true,
|
|
366
|
+
mouse: true,
|
|
367
|
+
border: { type: 'line' },
|
|
368
|
+
style: {
|
|
369
|
+
border: { fg: 'yellow' },
|
|
370
|
+
focus: { border: { fg: 'green' } },
|
|
371
|
+
fg: 'white',
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
const errorEl = blessed.box({
|
|
375
|
+
parent: box,
|
|
376
|
+
top: 10,
|
|
377
|
+
left: 4,
|
|
378
|
+
width: '100%-8',
|
|
379
|
+
height: 1,
|
|
380
|
+
tags: true,
|
|
381
|
+
});
|
|
382
|
+
inputBox.focus();
|
|
383
|
+
setFooter('Type a name | Enter: Save | Esc: Back to mapping | q: Quit');
|
|
384
|
+
inputBox.on('submit', (value) => {
|
|
385
|
+
const raw = (value ?? inputBox.getValue()).trim();
|
|
386
|
+
const name = raw.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
387
|
+
if (!name) {
|
|
388
|
+
errorEl.setContent('{red-fg}Please enter a name.{/red-fg}');
|
|
389
|
+
inputBox.clearValue();
|
|
390
|
+
inputBox.focus();
|
|
391
|
+
screen.render();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (maps.exists(name)) {
|
|
395
|
+
errorEl.setContent(`{yellow-fg}A map named "{bold}${name}{/bold}" already exists \u2014 it will be overwritten.{/yellow-fg} Saving...`);
|
|
396
|
+
screen.render();
|
|
397
|
+
}
|
|
398
|
+
maps.save({ name, mappings: pendingMappings });
|
|
399
|
+
startActive(name, pendingMappings);
|
|
400
|
+
});
|
|
401
|
+
inputBox.on('cancel', () => {
|
|
402
|
+
// Escape — go back to mapping mode with existing mappings preserved
|
|
403
|
+
showMappingMode();
|
|
404
|
+
});
|
|
405
|
+
screen.render();
|
|
406
|
+
}
|
|
407
|
+
// ── Active ───────────────────────────────────────────────────────────
|
|
408
|
+
function startActive(mapName, mappings) {
|
|
409
|
+
state = 'ACTIVE';
|
|
410
|
+
activeMapName = mapName;
|
|
411
|
+
const box = makeContentBox('MidiMapper \u2014 Running', 'green');
|
|
412
|
+
const infoEl = blessed.box({
|
|
413
|
+
parent: box,
|
|
414
|
+
top: 1,
|
|
415
|
+
left: 3,
|
|
416
|
+
width: '100%-6',
|
|
417
|
+
height: 6,
|
|
418
|
+
tags: true,
|
|
419
|
+
});
|
|
420
|
+
const mapEl = blessed.box({
|
|
421
|
+
parent: box,
|
|
422
|
+
top: 7,
|
|
423
|
+
left: 3,
|
|
424
|
+
width: '100%-6',
|
|
425
|
+
height: '100%-16',
|
|
426
|
+
scrollable: true,
|
|
427
|
+
alwaysScroll: true,
|
|
428
|
+
tags: true,
|
|
429
|
+
});
|
|
430
|
+
const lastEl = blessed.box({
|
|
431
|
+
parent: box,
|
|
432
|
+
bottom: 1,
|
|
433
|
+
left: 3,
|
|
434
|
+
width: '100%-6',
|
|
435
|
+
height: 2,
|
|
436
|
+
tags: true,
|
|
437
|
+
});
|
|
438
|
+
function renderInfo() {
|
|
439
|
+
infoEl.setContent(` {bold}Input device:{/bold} {cyan-fg}${selectedDeviceName}{/cyan-fg}\n` +
|
|
440
|
+
` {bold}Map:{/bold} {cyan-fg}${mapName}{/cyan-fg}\n` +
|
|
441
|
+
` {bold}Virtual output:{/bold} {cyan-fg}MidiMapper{/cyan-fg} {gray-fg}(connect this in your DAW){/gray-fg}\n` +
|
|
442
|
+
` {bold}Mappings:{/bold} {white-fg}${mappings.length}{/white-fg}`);
|
|
443
|
+
}
|
|
444
|
+
function renderMappings() {
|
|
445
|
+
if (mappings.length === 0) {
|
|
446
|
+
mapEl.setContent(' {gray-fg}(identity — all notes pass through unchanged){/gray-fg}');
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
mapEl.setContent(mappings
|
|
450
|
+
.map(m => ` {cyan-fg}${(0, notes_1.noteLabel)(m.from)}{/cyan-fg} \u2192 {green-fg}${(0, notes_1.noteLabel)(m.to)}{/green-fg}`)
|
|
451
|
+
.join('\n'));
|
|
452
|
+
}
|
|
453
|
+
function renderLast(from, to) {
|
|
454
|
+
if (from === undefined || to === undefined) {
|
|
455
|
+
lastEl.setContent(' {gray-fg}Last mapped note: \u2014{/gray-fg}');
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
lastEl.setContent(` {bold}Last:{/bold} {cyan-fg}${(0, notes_1.noteLabel)(from)}{/cyan-fg} \u2192 {green-fg}${(0, notes_1.noteLabel)(to)}{/green-fg}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
renderInfo();
|
|
462
|
+
renderMappings();
|
|
463
|
+
renderLast();
|
|
464
|
+
engine.startActive(selectedDevice, mappings, (from, to) => {
|
|
465
|
+
renderLast(from, to);
|
|
466
|
+
screen.render();
|
|
467
|
+
});
|
|
468
|
+
setFooter(`r: Change map | d: Change device | q: Quit` +
|
|
469
|
+
` {gray-fg}[map: ${activeMapName}]{/gray-fg}`);
|
|
470
|
+
screen.render();
|
|
471
|
+
}
|
|
472
|
+
// ── Start ────────────────────────────────────────────────────────────
|
|
473
|
+
showDeviceSelect();
|
|
474
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "notemap",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MIDI note mapper with terminal UI",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"notemap": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/samdesota/notemap.git"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"dev": "ts-node src/index.ts",
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"start": "node dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"blessed": "^0.1.81",
|
|
26
|
+
"midi": "^2.0.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/blessed": "^0.1.25",
|
|
30
|
+
"@types/midi": "^2.0.0",
|
|
31
|
+
"@types/node": "^20.0.0",
|
|
32
|
+
"ts-node": "^10.9.0",
|
|
33
|
+
"typescript": "^5.3.0"
|
|
34
|
+
}
|
|
35
|
+
}
|