lilylet-live-editor 0.0.4

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,529 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from 'svelte';
3
+ import { browser } from '$app/environment';
4
+ import { base } from '$app/paths';
5
+ import { editorStore } from '$lib/stores/editor';
6
+ import { getToolkit } from '$lib/verovio/toolkit';
7
+
8
+ // Music widgets will be loaded dynamically in browser only
9
+ let MIDI: any;
10
+ let MidiPlayer: any;
11
+ let MusicNotation: any;
12
+ let MidiAudio: any;
13
+
14
+ // Types for MIDI data structures
15
+ interface MidiEvent {
16
+ time: number;
17
+ data: {
18
+ type: string;
19
+ subtype: string;
20
+ channel: number;
21
+ noteNumber?: number;
22
+ velocity?: number;
23
+ programNumber?: number;
24
+ };
25
+ }
26
+
27
+ interface MidiNotation {
28
+ events: MidiEvent[];
29
+ endTime: number;
30
+ tempos?: Array<{ tempo: number; tick: number; time: number }>;
31
+ }
32
+
33
+ // State
34
+ let isPlaying = false;
35
+ let currentTime = 0;
36
+ let duration = 0;
37
+ let midiPlayer: any = null;
38
+ let midiData: MidiNotation | null = null;
39
+ let isAudioLoaded = false;
40
+ let audioLoadError: string | null = null;
41
+ let highlightedNotes: Set<string> = new Set();
42
+
43
+ // Element cache for performance
44
+ let elementCache: Map<string, Element | null> = new Map();
45
+
46
+ // Track which MEI we've initialized for (to prevent re-init during playback)
47
+ let initializedMei: string | null = null;
48
+
49
+ // Playback state
50
+ let updateInterval: number | null = null;
51
+ let playStartTime = 0;
52
+ let lastEventIndex = 0;
53
+ let pausedTime = 0;
54
+
55
+ // Throttle state for getElementsAtTime
56
+ let lastHighlightUpdate = 0;
57
+ const HIGHLIGHT_THROTTLE_MS = 50;
58
+
59
+ // Load music-widgets UMD bundle via script tag
60
+ async function loadMusicWidgets(): Promise<any> {
61
+ // Check if already loaded via UMD
62
+ if ((window as any).musicWidgetsBrowser) {
63
+ return (window as any).musicWidgetsBrowser;
64
+ }
65
+
66
+ // Try to load UMD bundle from static folder
67
+ return new Promise((resolve, reject) => {
68
+ const script = document.createElement('script');
69
+ script.src = `${import.meta.env.BASE_URL}js/musicWidgetsBrowser.umd.min.js`;
70
+ script.onload = () => {
71
+ if ((window as any).musicWidgetsBrowser) {
72
+ resolve((window as any).musicWidgetsBrowser);
73
+ } else {
74
+ reject(new Error('musicWidgetsBrowser not found on window after script load'));
75
+ }
76
+ };
77
+ script.onerror = () => reject(new Error('Failed to load music-widgets UMD bundle'));
78
+ document.head.appendChild(script);
79
+ });
80
+ }
81
+
82
+ onMount(async () => {
83
+ try {
84
+ // Load music-widgets via UMD bundle
85
+ const musicWidgets = await loadMusicWidgets();
86
+ MIDI = musicWidgets.MIDI;
87
+ MidiPlayer = musicWidgets.MidiPlayer;
88
+ MusicNotation = musicWidgets.MusicNotation;
89
+ MidiAudio = musicWidgets.MidiAudio;
90
+
91
+ await MidiAudio.loadPlugin({
92
+ soundfontUrl: `${base}/soundfont/`,
93
+ api: 'webaudio'
94
+ });
95
+ isAudioLoaded = true;
96
+ audioLoadError = null;
97
+ } catch (error) {
98
+ console.error('Failed to load MidiAudio:', error);
99
+ audioLoadError = error instanceof Error ? error.message : 'Failed to load audio';
100
+ }
101
+ });
102
+
103
+ async function initPlayer() {
104
+ if (!$editorStore.mei || !isAudioLoaded) return;
105
+
106
+ // Skip if already initialized for this MEI (prevents re-init when store updates during playback)
107
+ if ($editorStore.mei === initializedMei && midiPlayer) return;
108
+ initializedMei = $editorStore.mei;
109
+
110
+ try {
111
+ const toolkit = getToolkit();
112
+ if (!toolkit) return;
113
+
114
+ const midiBase64 = toolkit.renderToMIDI();
115
+ const binaryString = atob(midiBase64);
116
+ const bytes = new Uint8Array(binaryString.length);
117
+ for (let i = 0; i < binaryString.length; i++) {
118
+ bytes[i] = binaryString.charCodeAt(i);
119
+ }
120
+
121
+ const rawMidiData = MIDI.parseMidiData(bytes.buffer);
122
+ // Parse to notation and add default tempo if missing
123
+ const notation = MusicNotation.Notation.parseMidi(rawMidiData) as MidiNotation;
124
+ if (!notation.tempos || notation.tempos.length === 0) {
125
+ // Add default tempo (120 BPM = 500000 microseconds per beat)
126
+ notation.tempos = [{ tempo: 500000, tick: 0, time: 0 }];
127
+ }
128
+ midiData = notation;
129
+
130
+ if (midiPlayer) {
131
+ midiPlayer.dispose();
132
+ }
133
+
134
+ midiPlayer = new MidiPlayer(midiData, {
135
+ cacheSpan: 400,
136
+ onMidi: (data: MidiEvent['data'], timestamp: number) => {
137
+ switch (data.subtype) {
138
+ case 'noteOn':
139
+ MidiAudio.noteOn(data.channel, data.noteNumber, data.velocity, timestamp);
140
+ break;
141
+ case 'noteOff':
142
+ MidiAudio.noteOff(data.channel, data.noteNumber, timestamp);
143
+ break;
144
+ case 'programChange':
145
+ MidiAudio.programChange(data.channel, data.programNumber);
146
+ break;
147
+ }
148
+ },
149
+ onPlayFinish: () => {
150
+ if (updateInterval) {
151
+ clearInterval(updateInterval);
152
+ updateInterval = null;
153
+ }
154
+ isPlaying = false;
155
+ currentTime = 0;
156
+ pausedTime = 0;
157
+ lastEventIndex = 0;
158
+ clearHighlights();
159
+ }
160
+ });
161
+
162
+ duration = midiData.endTime;
163
+
164
+ // Clear element cache when new score is loaded
165
+ elementCache.clear();
166
+
167
+ // Reset playback state
168
+ stop();
169
+ } catch (error) {
170
+ console.error('Failed to initialize player:', error);
171
+ }
172
+ }
173
+
174
+ function findEventIndexAtTime(time: number): number {
175
+ if (!midiData) return 0;
176
+ const events = midiData.events;
177
+ // Binary search for first event at or after time
178
+ let low = 0;
179
+ let high = events.length;
180
+ while (low < high) {
181
+ const mid = (low + high) >>> 1;
182
+ if (events[mid].time < time) {
183
+ low = mid + 1;
184
+ } else {
185
+ high = mid;
186
+ }
187
+ }
188
+ return low;
189
+ }
190
+
191
+ function play() {
192
+ if (!midiPlayer || !midiData || isPlaying) return;
193
+ isPlaying = true;
194
+
195
+ // Resume from pausedTime if we were paused, otherwise start from 0
196
+ if (pausedTime > 0) {
197
+ playStartTime = performance.now() - pausedTime;
198
+ lastEventIndex = findEventIndexAtTime(pausedTime);
199
+ } else {
200
+ playStartTime = performance.now();
201
+ lastEventIndex = 0;
202
+ }
203
+
204
+ // Start interval to drive playback manually
205
+ updateInterval = setInterval(() => {
206
+ if (!isPlaying || !midiData) return;
207
+
208
+ const now = performance.now();
209
+ const elapsed = now - playStartTime;
210
+ currentTime = elapsed;
211
+
212
+ // Send MIDI events that should have played by now
213
+ const events = midiData.events;
214
+ for (; lastEventIndex < events.length; lastEventIndex++) {
215
+ const event = events[lastEventIndex];
216
+ if (event.time > elapsed) break;
217
+
218
+ // Send channel events (noteOn, noteOff, etc.)
219
+ if (event.data.type === 'channel') {
220
+ const timestamp = playStartTime + event.time;
221
+ switch (event.data.subtype) {
222
+ case 'noteOn':
223
+ MidiAudio.noteOn(event.data.channel, event.data.noteNumber, event.data.velocity, timestamp);
224
+ break;
225
+ case 'noteOff':
226
+ MidiAudio.noteOff(event.data.channel, event.data.noteNumber, timestamp);
227
+ break;
228
+ case 'programChange':
229
+ MidiAudio.programChange(event.data.channel, event.data.programNumber);
230
+ break;
231
+ }
232
+ }
233
+ }
234
+
235
+ // Update highlights (throttled)
236
+ updateHighlightsThrottled(currentTime);
237
+
238
+ // Check if playback finished
239
+ if (elapsed >= duration) {
240
+ stop();
241
+ }
242
+ }, 30) as unknown as number;
243
+ }
244
+
245
+ function pause() {
246
+ if (updateInterval) {
247
+ clearInterval(updateInterval);
248
+ updateInterval = null;
249
+ }
250
+ pausedTime = currentTime;
251
+ isPlaying = false;
252
+ // Stop all notes
253
+ MidiAudio.stopAllNotes?.();
254
+ }
255
+
256
+ function stop() {
257
+ if (updateInterval) {
258
+ clearInterval(updateInterval);
259
+ updateInterval = null;
260
+ }
261
+ isPlaying = false;
262
+ currentTime = 0;
263
+ pausedTime = 0;
264
+ lastEventIndex = 0;
265
+ clearHighlights();
266
+ // Stop all notes
267
+ MidiAudio.stopAllNotes?.();
268
+ }
269
+
270
+ function seekTo(targetTime: number) {
271
+ if (!midiData) return;
272
+
273
+ // Clamp to valid range
274
+ targetTime = Math.max(0, Math.min(targetTime, duration));
275
+
276
+ // Stop all currently playing notes
277
+ MidiAudio.stopAllNotes?.();
278
+
279
+ // Update state
280
+ currentTime = targetTime;
281
+ pausedTime = targetTime;
282
+ lastEventIndex = findEventIndexAtTime(targetTime);
283
+
284
+ // If playing, adjust playStartTime to maintain continuity
285
+ if (isPlaying) {
286
+ playStartTime = performance.now() - targetTime;
287
+ }
288
+
289
+ // Update highlights immediately
290
+ updateHighlights(targetTime);
291
+ }
292
+
293
+ function handleProgressBarClick(e: MouseEvent) {
294
+ if (!midiData) return;
295
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
296
+ const x = e.clientX - rect.left;
297
+ const percent = x / rect.width;
298
+ seekTo(duration * percent);
299
+ }
300
+
301
+ function getElement(id: string): Element | null {
302
+ if (!elementCache.has(id)) {
303
+ elementCache.set(id, document.getElementById(id));
304
+ }
305
+ return elementCache.get(id) || null;
306
+ }
307
+
308
+ function updateHighlightsThrottled(time: number) {
309
+ const now = performance.now();
310
+ if (now - lastHighlightUpdate < HIGHLIGHT_THROTTLE_MS) return;
311
+ lastHighlightUpdate = now;
312
+ updateHighlights(time);
313
+ }
314
+
315
+ function updateHighlights(time: number) {
316
+ const toolkit = getToolkit();
317
+ if (!toolkit) return;
318
+
319
+ try {
320
+ // Get elements at current time (time is in ms)
321
+ const result = toolkit.getElementsAtTime(time);
322
+ const newNotes = new Set<string>(result.notes || []);
323
+
324
+ // Remove highlights from notes no longer playing
325
+ highlightedNotes.forEach(id => {
326
+ if (!newNotes.has(id)) {
327
+ const element = getElement(id);
328
+ if (element) {
329
+ element.classList.remove('verovio-highlight');
330
+ }
331
+ }
332
+ });
333
+
334
+ // Add highlights to new notes
335
+ newNotes.forEach(id => {
336
+ if (!highlightedNotes.has(id)) {
337
+ const element = getElement(id);
338
+ if (element) {
339
+ element.classList.add('verovio-highlight');
340
+ }
341
+ }
342
+ });
343
+
344
+ highlightedNotes = newNotes;
345
+
346
+ // Update cursor position - use the first note as cursor position
347
+ const noteIds = result.notes || [];
348
+ if (noteIds.length > 0) {
349
+ editorStore.setCursorElement(noteIds[0]);
350
+ }
351
+ } catch (error) {
352
+ // Ignore errors during highlight update
353
+ }
354
+ }
355
+
356
+ function clearHighlights() {
357
+ highlightedNotes.forEach(id => {
358
+ const element = getElement(id);
359
+ if (element) {
360
+ element.classList.remove('verovio-highlight');
361
+ }
362
+ });
363
+ highlightedNotes = new Set();
364
+ editorStore.setCursorElement(null);
365
+ }
366
+
367
+ function formatTime(ms: number): string {
368
+ const seconds = Math.floor(ms / 1000);
369
+ const minutes = Math.floor(seconds / 60);
370
+ const remainingSeconds = seconds % 60;
371
+ return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
372
+ }
373
+
374
+ // Clear element cache when SVG changes
375
+ $: if ($editorStore.svg) {
376
+ elementCache.clear();
377
+ }
378
+
379
+ $: if ($editorStore.mei && isAudioLoaded) {
380
+ initPlayer();
381
+ }
382
+
383
+ onDestroy(() => {
384
+ stop();
385
+ if (midiPlayer) {
386
+ midiPlayer.dispose();
387
+ }
388
+ });
389
+ </script>
390
+
391
+ <div class="player-container">
392
+ <div class="controls">
393
+ {#if !isPlaying}
394
+ <button class="control-btn play-btn" on:click={play} disabled={!midiPlayer || !isAudioLoaded} title="Play">
395
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
396
+ <path d="M3 2v12l10-6z" />
397
+ </svg>
398
+ </button>
399
+ {:else}
400
+ <button class="control-btn pause-btn" on:click={pause} title="Pause">
401
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
402
+ <path d="M4 2h3v12H4zM9 2h3v12H9z" />
403
+ </svg>
404
+ </button>
405
+ {/if}
406
+ <button class="control-btn stop-btn" on:click={stop} disabled={!isPlaying && currentTime === 0} title="Stop">
407
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
408
+ <rect x="3" y="3" width="10" height="10" />
409
+ </svg>
410
+ </button>
411
+ {#if audioLoadError}
412
+ <span class="error-text" title={audioLoadError}>Audio error</span>
413
+ {:else if !isAudioLoaded}
414
+ <span class="loading-text">Loading audio...</span>
415
+ {/if}
416
+ </div>
417
+
418
+ <div class="time-display">
419
+ <span class="time">{formatTime(currentTime)}</span>
420
+ <span class="separator">/</span>
421
+ <span class="duration">{formatTime(duration)}</span>
422
+ </div>
423
+
424
+ <div class="progress-bar" role="progressbar" on:click={handleProgressBarClick}>
425
+ <div class="progress-fill" style="width: {duration > 0 ? (currentTime / duration) * 100 : 0}%"></div>
426
+ </div>
427
+ </div>
428
+
429
+ <style>
430
+ .player-container {
431
+ display: flex;
432
+ align-items: center;
433
+ gap: 12px;
434
+ padding: 8px 16px;
435
+ background: #2d2d30;
436
+ border-top: 1px solid #1e1e1e;
437
+ }
438
+
439
+ .controls {
440
+ display: flex;
441
+ gap: 4px;
442
+ align-items: center;
443
+ }
444
+
445
+ .control-btn {
446
+ background: transparent;
447
+ border: 1px solid #3c3c3c;
448
+ color: #cccccc;
449
+ width: 32px;
450
+ height: 32px;
451
+ border-radius: 4px;
452
+ cursor: pointer;
453
+ display: flex;
454
+ align-items: center;
455
+ justify-content: center;
456
+ transition: all 0.2s;
457
+ }
458
+
459
+ .control-btn:hover:not(:disabled) {
460
+ background: #3c3c3c;
461
+ border-color: #569cd6;
462
+ }
463
+
464
+ .control-btn:disabled {
465
+ opacity: 0.4;
466
+ cursor: not-allowed;
467
+ }
468
+
469
+ .play-btn:hover:not(:disabled) {
470
+ color: #4ec9b0;
471
+ }
472
+
473
+ .pause-btn {
474
+ color: #dcdcaa;
475
+ }
476
+
477
+ .stop-btn:hover:not(:disabled) {
478
+ color: #f48771;
479
+ }
480
+
481
+ .loading-text {
482
+ color: #858585;
483
+ font-size: 11px;
484
+ margin-left: 8px;
485
+ }
486
+
487
+ .error-text {
488
+ color: #f48771;
489
+ font-size: 11px;
490
+ margin-left: 8px;
491
+ cursor: help;
492
+ }
493
+
494
+ .time-display {
495
+ display: flex;
496
+ gap: 4px;
497
+ font-size: 12px;
498
+ color: #cccccc;
499
+ font-family: 'Consolas', 'Monaco', monospace;
500
+ min-width: 80px;
501
+ }
502
+
503
+ .separator {
504
+ color: #858585;
505
+ }
506
+
507
+ .progress-bar {
508
+ flex: 1;
509
+ height: 4px;
510
+ background: #3c3c3c;
511
+ border-radius: 2px;
512
+ cursor: pointer;
513
+ position: relative;
514
+ overflow: hidden;
515
+ }
516
+
517
+ .progress-fill {
518
+ height: 100%;
519
+ background: #569cd6;
520
+ transition: width 0.1s linear;
521
+ }
522
+
523
+ :global(.verovio-highlight) {
524
+ fill: #ff6b35 !important;
525
+ stroke: #ff6b35 !important;
526
+ stroke-width: 1px;
527
+ filter: drop-shadow(0 0 3px #ff6b35);
528
+ }
529
+ </style>