virtual-piano-player 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.
@@ -0,0 +1,481 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * OnlinePianist Virtual Piano Automated Player
5
+ * Controls browser, sets visible keys to 88, and plays songs with studio-grade audio synthesis.
6
+ */
7
+
8
+ const puppeteer = require('puppeteer-core');
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { execSync } = require('child_process');
12
+
13
+ // 88-Key Note-to-Keyboard Mapping (Fallback & Reverse Lookup)
14
+ const NOTE_MAP = {
15
+ 'C2': '1', 'C#2': '!', 'Db2': '!', 'D2': '2', 'D#2': '@', 'Eb2': '@',
16
+ 'E2': '3', 'F2': '4', 'F#2': '$', 'Gb2': '$', 'G2': '5', 'G#2': '%', 'Ab2': '%',
17
+ 'A2': '6', 'A#2': '^', 'Bb2': '^', 'B2': '7',
18
+ 'C3': '8', 'C#3': '*', 'Db3': '*', 'D3': '9', 'D#3': '(', 'Eb3': '(',
19
+ 'E3': '0', 'F3': 'q', 'F#3': 'Q', 'Gb3': 'Q', 'G3': 'w', 'G#3': 'W', 'Ab3': 'W',
20
+ 'A3': 'e', 'A#3': 'E', 'Bb3': 'E', 'B3': 'r',
21
+ 'C4': 't', 'C#4': 'T', 'Db4': 'T', 'D4': 'y', 'D#4': 'Y', 'Eb4': 'Y',
22
+ 'E4': 'u', 'F4': 'i', 'F#4': 'I', 'Gb4': 'I', 'G4': 'o', 'G#4': 'O', 'Ab4': 'O',
23
+ 'A4': 'p', 'A#4': 'P', 'Bb4': 'P', 'B4': 'a',
24
+ 'C5': 's', 'C#5': 'S', 'Db5': 'S', 'D5': 'd', 'D#5': 'D', 'Eb5': 'D',
25
+ 'E5': 'f', 'F5': 'g', 'F#5': 'G', 'Gb5': 'G', 'G5': 'h', 'G#5': 'H', 'Ab5': 'H',
26
+ 'A5': 'j', 'A#5': 'J', 'Bb5': 'J', 'B5': 'k',
27
+ 'C6': 'l', 'C#6': 'L', 'Db6': 'L', 'D6': 'z', 'D#6': 'Z', 'Eb6': 'Z',
28
+ 'E6': 'x', 'F6': 'c', 'F#6': 'C', 'Gb6': 'C', 'G6': 'v', 'G#6': 'V', 'Ab6': 'V',
29
+ 'A6': 'b', 'A#6': 'B', 'Bb6': 'B', 'B6': 'n',
30
+ 'C7': 'm'
31
+ };
32
+
33
+ const NOTE_OFFSETS = {
34
+ 'C': 0, 'C#': 1, 'DB': 1,
35
+ 'D': 2, 'D#': 3, 'EB': 3,
36
+ 'E': 4,
37
+ 'F': 5, 'F#': 6, 'GB': 6,
38
+ 'G': 7, 'G#': 8, 'AB': 8,
39
+ 'A': 9, 'A#': 10, 'BB': 10,
40
+ 'B': 11
41
+ };
42
+
43
+ function pitchStringToMidi(note) {
44
+ if (typeof note !== 'string') return null;
45
+ const normalized = note.trim().replace(/^([A-Ga-g])s(\d+)$/, '$1#$2');
46
+ const m = normalized.match(/^([A-Ga-g][#b]?)(-?\d+)$/);
47
+ if (!m) return null;
48
+ const name = m[1].toUpperCase();
49
+ const oct = parseInt(m[2], 10);
50
+ const offset = NOTE_OFFSETS[name];
51
+ if (offset === undefined) return null;
52
+ return (oct + 1) * 12 + offset;
53
+ }
54
+
55
+ const CHAR_TO_MIDI = {};
56
+ for (const [note, char] of Object.entries(NOTE_MAP)) {
57
+ const m = pitchStringToMidi(note);
58
+ if (m !== null) CHAR_TO_MIDI[char] = m;
59
+ }
60
+
61
+ function noteToMidi(note) {
62
+ if (typeof note === 'number') return note;
63
+ if (!note || typeof note !== 'string') return null;
64
+ const trimmed = note.trim();
65
+ const fromPitch = pitchStringToMidi(trimmed);
66
+ if (fromPitch !== null) return fromPitch;
67
+ if (CHAR_TO_MIDI[trimmed] !== undefined) return CHAR_TO_MIDI[trimmed];
68
+ return null;
69
+ }
70
+
71
+ function listSongs() {
72
+ const songsDir = path.join(__dirname, 'songs');
73
+ const files = fs.existsSync(songsDir) ? fs.readdirSync(songsDir).filter(f => f.endsWith('.json')) : [];
74
+
75
+ const SONG_ORDER = [
76
+ { file: 'amelie.json', alias: 'amelie', shortcut: 'play-amelie' },
77
+ { file: 'chopin_nocturne.json', alias: 'nocturne', shortcut: 'play-nocturne' },
78
+ { file: 'succession.json', alias: 'succession', shortcut: 'play-succession' },
79
+ { file: 'still_dre.json', alias: 'still', shortcut: 'play-still' },
80
+ { file: 'paint_it_black.json', alias: 'paint', shortcut: 'play-paint' },
81
+ { file: 'vivaldi_winter.json', alias: 'winter', shortcut: 'play-winter' },
82
+ { file: 'chopin_etude.json', alias: 'chopin', shortcut: 'play-chopin' },
83
+ { file: 'fur_elise.json', alias: 'elise', shortcut: 'play-elise' }
84
+ ];
85
+
86
+ const seen = new Set();
87
+ const rows = [];
88
+
89
+ for (const item of SONG_ORDER) {
90
+ const fPath = path.join(songsDir, item.file);
91
+ if (fs.existsSync(fPath)) {
92
+ seen.add(item.file);
93
+ try {
94
+ const d = JSON.parse(fs.readFileSync(fPath, 'utf8'));
95
+ let dur = d.targetDurationSec;
96
+ if (!dur && d.notes) {
97
+ dur = Math.round(Math.max(...d.notes.map(n => n.startMs + n.durMs)) / 1000);
98
+ } else if (!dur && d.events) {
99
+ dur = Math.round(d.events.reduce((acc, e) => acc + (e.dur || 0) + (e.wait || 0), 0) / 1000);
100
+ }
101
+ rows.push({
102
+ alias: item.alias,
103
+ shortcut: item.shortcut,
104
+ title: d.title || item.file,
105
+ composer: d.composer || 'Unknown',
106
+ key: d.key || '-',
107
+ duration: `~${dur || 60}s`
108
+ });
109
+ } catch (_) {}
110
+ }
111
+ }
112
+
113
+ for (const f of files) {
114
+ if (seen.has(f) || f === 'comptine_dun_autre_ete.json') continue;
115
+ try {
116
+ const fPath = path.join(songsDir, f);
117
+ const d = JSON.parse(fs.readFileSync(fPath, 'utf8'));
118
+ const base = f.replace('.json', '');
119
+ rows.push({
120
+ alias: base,
121
+ shortcut: `play-${base}`,
122
+ title: d.title || f,
123
+ composer: d.composer || 'Custom',
124
+ key: d.key || '-',
125
+ duration: d.targetDurationSec ? `~${d.targetDurationSec}s` : '-'
126
+ });
127
+ } catch (_) {}
128
+ }
129
+
130
+ console.log('\n====================================================================================================');
131
+ console.log(' 🎹 ONLINEPIANIST VIRTUAL PIANO — SONG CATALOG');
132
+ console.log('====================================================================================================');
133
+ console.log(` ${'ALIAS'.padEnd(12)} | ${'SHORTCUT'.padEnd(17)} | ${'TITLE'.padEnd(32)} | ${'COMPOSER'.padEnd(20)} | ${'KEY'.padEnd(9)} | ${'DUR'}`);
134
+ console.log('----------------------------------------------------------------------------------------------------');
135
+ rows.forEach(r => {
136
+ const title = r.title.length > 32 ? r.title.slice(0, 29) + '...' : r.title;
137
+ const composer = r.composer.length > 20 ? r.composer.slice(0, 17) + '...' : r.composer;
138
+ console.log(` ${r.alias.padEnd(12)} | ${r.shortcut.padEnd(17)} | ${title.padEnd(32)} | ${composer.padEnd(20)} | ${r.key.padEnd(9)} | ${r.duration}`);
139
+ });
140
+ console.log('====================================================================================================');
141
+ console.log('\nQuick Play Commands:');
142
+ console.log(' piano <alias> e.g. piano amelie, piano succession, piano still');
143
+ console.log(' <shortcut> e.g. play-amelie, play-succession, play-still');
144
+ console.log(' node play.js <alias> (when inside this directory)\n');
145
+ }
146
+
147
+ function parseArgs() {
148
+ const args = process.argv.slice(2);
149
+
150
+ if (args.length === 0 || args[0] === '--list' || args[0] === 'list' || args[0] === '-l' || args[0] === 'songs') {
151
+ listSongs();
152
+ process.exit(0);
153
+ }
154
+
155
+ const options = {
156
+ song: null,
157
+ file: null,
158
+ tempo: 1.0,
159
+ headless: false,
160
+ sustain: true,
161
+ chromePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
162
+ };
163
+
164
+ for (let i = 0; i < args.length; i++) {
165
+ if ((args[i] === '--song' || args[i] === '-s') && args[i + 1]) {
166
+ options.song = args[++i];
167
+ } else if ((args[i] === '--file' || args[i] === '-f') && args[i + 1]) {
168
+ options.file = args[++i];
169
+ } else if ((args[i] === '--tempo' || args[i] === '-t') && args[i + 1]) {
170
+ options.tempo = parseFloat(args[++i]) || 1.0;
171
+ } else if (args[i] === '--headless') {
172
+ options.headless = args[i + 1] === 'true';
173
+ if (args[i + 1] === 'true' || args[i + 1] === 'false') i++;
174
+ } else if (args[i] === '--sustain') {
175
+ options.sustain = args[i + 1] !== 'false';
176
+ if (args[i + 1] === 'true' || args[i + 1] === 'false') i++;
177
+ } else if (args[i] === '--list' || args[i] === '-l' || args[i] === 'list') {
178
+ listSongs();
179
+ process.exit(0);
180
+ } else if (args[i] === '--help' || args[i] === '-h') {
181
+ console.log(`
182
+ Virtual Piano Player CLI
183
+ Usage: node play.js [options] [song_alias]
184
+
185
+ Options:
186
+ --song, -s <name> Preset song name or alias (e.g. "amelie", "succession", "still")
187
+ --list, -l List all available songs and shortcuts
188
+ --file, -f <path> Path to custom song JSON file
189
+ --tempo, -t <float> Tempo multiplier (default: 1.0; e.g. 1.2 for faster, 0.8 for slower)
190
+ --headless <bool> Run in headless mode (default: false)
191
+ --sustain <bool> Enable sustain pedal (default: true)
192
+ --help, -h Show this help message
193
+ `);
194
+ process.exit(0);
195
+ } else if (!options.song && !args[i].startsWith('-')) {
196
+ options.song = args[i];
197
+ }
198
+ }
199
+
200
+ if (!options.song && !options.file) {
201
+ listSongs();
202
+ process.exit(0);
203
+ }
204
+
205
+ return options;
206
+ }
207
+
208
+ function loadSong(options) {
209
+ let filePath = options.file;
210
+
211
+ if (!filePath) {
212
+ const songsDir = path.join(__dirname, 'songs');
213
+ const s = (options.song || '').toLowerCase().trim();
214
+ if (s === 'nocturne' || s === 'chopin_nocturne' || s === 'chopin-nocturne' || s === 'op9' || s === 'op9no2' || s === 'csabay' || s === 'domonkos') {
215
+ filePath = path.join(songsDir, 'chopin_nocturne.json');
216
+ } else if (s === 'chopin' || s === 'chopin_etude') {
217
+ filePath = path.join(songsDir, 'chopin_etude.json');
218
+ } else if (s === 'fur_elise' || s === 'beethoven' || s === 'elise') {
219
+ filePath = path.join(songsDir, 'fur_elise.json');
220
+ } else if (s === 'winter' || s === 'vivaldi' || s === 'vivaldi_winter') {
221
+ filePath = path.join(songsDir, 'vivaldi_winter.json');
222
+ } else if (s === 'paint_it_black' || s === 'paint' || s === 'paintitblack' || s === 'stones') {
223
+ filePath = path.join(songsDir, 'paint_it_black.json');
224
+ } else if (s === 'still' || s === 'still_dre' || s === 'stilldre' || s === 'snoop' || s === 'snoopdog' || s === 'dre') {
225
+ filePath = path.join(songsDir, 'still_dre.json');
226
+ } else if (s === 'succession' || s === 'britell' || s === 'succession_theme' || s === 'roy') {
227
+ filePath = path.join(songsDir, 'succession.json');
228
+ } else if (s === 'amelie' || s === 'comptine' || s === 'comptine_dun_autre_ete' || s === 'tiersen' || s === 'yann_tiersen') {
229
+ filePath = path.join(songsDir, 'amelie.json');
230
+ } else {
231
+ filePath = path.join(songsDir, `${s}.json`);
232
+ }
233
+ }
234
+
235
+ if (!fs.existsSync(filePath)) {
236
+ throw new Error(`Song file not found at: ${filePath}`);
237
+ }
238
+
239
+ const raw = fs.readFileSync(filePath, 'utf8');
240
+ return JSON.parse(raw);
241
+ }
242
+
243
+ async function main() {
244
+ const options = parseArgs();
245
+ const songData = loadSong(options);
246
+
247
+ console.log(`Loaded song: "${songData.title}" by ${songData.composer}`);
248
+ const noteCount = songData.notes ? songData.notes.length : (songData.events ? songData.events.length : 0);
249
+ console.log(`Track elements: ${noteCount}, Base tempo multiplier: ${options.tempo}`);
250
+
251
+ let browser;
252
+ try {
253
+ // Clean up any stale or orphaned piano browser instances and lock files
254
+ try {
255
+ execSync('pkill -9 -f "chrome-piano-profile" 2>/dev/null || true');
256
+ execSync('rm -f /tmp/chrome-piano-profile/Singleton* 2>/dev/null || true');
257
+ await new Promise(r => setTimeout(r, 200));
258
+ } catch (_) {}
259
+
260
+ console.log('Launching browser to play virtual piano...');
261
+ browser = await puppeteer.launch({
262
+ executablePath: options.chromePath,
263
+ headless: options.headless,
264
+ defaultViewport: null,
265
+ args: [
266
+ '--start-maximized',
267
+ '--user-data-dir=/tmp/chrome-piano-profile',
268
+ '--autoplay-policy=no-user-gesture-required'
269
+ ]
270
+ });
271
+
272
+ // Register signal handlers for clean exit on Ctrl+C or kill
273
+ const cleanup = async () => {
274
+ try {
275
+ if (browser) await browser.close();
276
+ } catch (_) {}
277
+ try {
278
+ execSync('pkill -9 -f "chrome-piano-profile" 2>/dev/null || true');
279
+ } catch (_) {}
280
+ process.exit(0);
281
+ };
282
+ process.on('SIGINT', cleanup);
283
+ process.on('SIGTERM', cleanup);
284
+
285
+ const page = (await browser.pages())[0] || await browser.newPage();
286
+
287
+ if (!options.headless) {
288
+ try {
289
+ execSync('osascript -e \'tell application "Google Chrome" to activate\'');
290
+ } catch (_) {}
291
+ }
292
+
293
+ console.log('Navigating to https://www.onlinepianist.com/virtual-piano...');
294
+ await page.goto('https://www.onlinepianist.com/virtual-piano', {
295
+ waitUntil: 'domcontentloaded',
296
+ timeout: 35000
297
+ });
298
+
299
+ console.log('Waiting for piano audio samples to load...');
300
+ await page.waitForFunction(() => !document.body.innerText.includes('WARMING UP PIANO'), {
301
+ timeout: 45000
302
+ });
303
+ console.log('Piano audio engine is ready.');
304
+
305
+ // Configure keyboard settings: Set layout to Full and visible keys to Max (88 keys)
306
+ console.log('Configuring keyboard: Setting layout to Full and visible keys to Max (88 keys)...');
307
+ await page.click('.synth-btn--settings');
308
+ await new Promise(r => setTimeout(r, 600));
309
+
310
+ await page.evaluate(() => {
311
+ const buttons = Array.from(document.querySelectorAll('button, div, span, .keyboard-panel-option'));
312
+ const fullBtn = buttons.find(b => b.innerText?.trim() === 'Full');
313
+ if (fullBtn) fullBtn.click();
314
+ const maxBtn = buttons.find(b => b.innerText?.trim() === 'Max');
315
+ if (maxBtn) maxBtn.click();
316
+ });
317
+ await new Promise(r => setTimeout(r, 600));
318
+
319
+ await page.click('.synth-btn--settings');
320
+ await new Promise(r => setTimeout(r, 400));
321
+
322
+ // Scroll keyboard into view
323
+ await page.evaluate(() => {
324
+ const kb = document.querySelector('.piano-keyboard-wrap');
325
+ if (kb) kb.scrollIntoView({ behavior: 'instant', block: 'end' });
326
+ });
327
+ await new Promise(r => setTimeout(r, 300));
328
+
329
+ const totalKeys = await page.evaluate(() => document.querySelectorAll('.piano-key-white, .piano-key-black').length);
330
+ console.log(`Keyboard display active with all ${totalKeys} keys visible.`);
331
+
332
+ // Configure Sustain
333
+ const desiredSustain = songData.sustain !== undefined ? songData.sustain : options.sustain;
334
+ const sustainOn = await page.evaluate(() => {
335
+ const btn = document.querySelector('.synth-btn--sustain');
336
+ return btn ? btn.classList.contains('synth-btn--on') : true;
337
+ });
338
+
339
+ if (desiredSustain && !sustainOn) {
340
+ console.log('Activating Sustain pedal...');
341
+ await page.click('.synth-btn--sustain');
342
+ } else if (!desiredSustain && sustainOn) {
343
+ console.log('Deactivating Sustain pedal...');
344
+ await page.click('.synth-btn--sustain');
345
+ }
346
+
347
+ // Inject Native Audio Bridge
348
+ console.log('Injecting native audio bridge into OnlinePianist engine...');
349
+ const bridgeInjected = await page.evaluate(() => {
350
+ const key = document.querySelector('.piano-key-white[data-midi="60"]') || document.querySelector('[data-midi]');
351
+ if (!key) return false;
352
+ const fiberKey = Object.keys(key).find(k => k.startsWith('__reactFiber'));
353
+ if (!fiberKey) return false;
354
+ let curr = key[fiberKey];
355
+ let playFn = null;
356
+ let releaseFn = null;
357
+
358
+ while (curr) {
359
+ let h = curr.memoizedState;
360
+ while (h) {
361
+ if (Array.isArray(h.memoizedState) && typeof h.memoizedState[0] === 'function') {
362
+ const s = h.memoizedState[0].toString();
363
+ if (s.includes('playNote') && s.includes('octaveShift')) playFn = h.memoizedState[0];
364
+ if (s.includes('releaseNote') && s.includes('octaveShift')) releaseFn = h.memoizedState[0];
365
+ }
366
+ h = h.next;
367
+ }
368
+ if (playFn && releaseFn) break;
369
+ curr = curr.return;
370
+ }
371
+
372
+ if (!playFn || !releaseFn) return false;
373
+
374
+ window.__playMidi = (midi) => {
375
+ playFn(midi);
376
+ const el = document.querySelector(`[data-midi="${midi}"]`);
377
+ if (el) {
378
+ el.classList.add('piano-key--active');
379
+ const glow = el.querySelector('.key-glow');
380
+ if (glow) glow.classList.add('key-glow--active');
381
+ }
382
+ };
383
+
384
+ window.__releaseMidi = (midi) => {
385
+ releaseFn(midi);
386
+ const el = document.querySelector(`[data-midi="${midi}"]`);
387
+ if (el) {
388
+ el.classList.remove('piano-key--active');
389
+ const glow = el.querySelector('.key-glow');
390
+ if (glow) glow.classList.remove('key-glow--active');
391
+ }
392
+ };
393
+
394
+ return true;
395
+ });
396
+
397
+ console.log(`Native audio bridge status: ${bridgeInjected ? 'CONNECTED (Studio Direct)' : 'FALLBACK (Keyboard Sim)'}`);
398
+
399
+ console.log('================================================================');
400
+ console.log(`NOW PLAYING: ${songData.title}`);
401
+ console.log('================================================================');
402
+
403
+ const tempoMultiplier = options.tempo || 1.0;
404
+ const startTime = Date.now();
405
+
406
+ if (songData.notes && bridgeInjected) {
407
+ // High-precision timeline playback in browser
408
+ console.log(`Streaming ${songData.notes.length} notes via client-side timeline...`);
409
+ await page.evaluate((notes, mult) => {
410
+ return new Promise((resolve) => {
411
+ const maxTime = Math.max(...notes.map(n => n.startMs + n.durMs)) / mult;
412
+ notes.forEach(n => {
413
+ const sTime = n.startMs / mult;
414
+ const sDur = n.durMs / mult;
415
+ setTimeout(() => {
416
+ window.__playMidi(n.midi);
417
+ setTimeout(() => window.__releaseMidi(n.midi), sDur);
418
+ }, sTime);
419
+ });
420
+ setTimeout(resolve, maxTime + 1000);
421
+ });
422
+ }, songData.notes, tempoMultiplier);
423
+ } else if (songData.events) {
424
+ // Step-by-step playback with direct audio bridge or keyboard fallback
425
+ let nextStepTime = startTime;
426
+ for (let i = 0; i < songData.events.length; i++) {
427
+ const event = songData.events[i];
428
+ const dur = Math.max(20, Math.round(event.dur * tempoMultiplier));
429
+ const wait = Math.max(0, Math.round(event.wait * tempoMultiplier));
430
+ const stepTotal = dur + wait;
431
+
432
+ if (bridgeInjected) {
433
+ const midis = event.keys.map(k => noteToMidi(k)).filter(m => m !== null);
434
+ if (midis.length > 0) {
435
+ await page.evaluate((mList) => mList.forEach(m => window.__playMidi(m)), midis);
436
+ }
437
+ await new Promise(r => setTimeout(r, dur));
438
+ if (midis.length > 0) {
439
+ await page.evaluate((mList) => mList.forEach(m => window.__releaseMidi(m)), midis);
440
+ }
441
+ } else {
442
+ const chars = event.keys.map(k => NOTE_MAP[k] || k).filter(Boolean);
443
+ if (chars.length > 0) {
444
+ await Promise.all(chars.map(c => page.keyboard.down(c)));
445
+ }
446
+ await new Promise(r => setTimeout(r, dur));
447
+ if (chars.length > 0) {
448
+ await Promise.all(chars.map(c => page.keyboard.up(c)));
449
+ }
450
+ }
451
+
452
+ nextStepTime += stepTotal;
453
+ const remainingWait = nextStepTime - Date.now();
454
+ if (remainingWait > 0) {
455
+ await new Promise(r => setTimeout(r, remainingWait));
456
+ }
457
+ }
458
+ }
459
+
460
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
461
+ console.log('================================================================');
462
+ console.log(`Playback complete! Total duration: ${elapsed}s`);
463
+ console.log('================================================================');
464
+
465
+ console.log('Allowing final chord resonance to ring out...');
466
+ await new Promise(r => setTimeout(r, 6000));
467
+
468
+ await browser.close();
469
+ console.log('Browser session finished.');
470
+ } catch (err) {
471
+ console.error('Playback failed:', err);
472
+ if (browser) await browser.close();
473
+ process.exit(1);
474
+ }
475
+ }
476
+
477
+ if (require.main === module) {
478
+ main();
479
+ }
480
+
481
+ module.exports = { NOTE_MAP, noteToMidi, loadSong, listSongs, main };