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.
- package/.github/workflows/deploy.yml +81 -0
- package/README.md +78 -0
- package/package.json +50 -0
- package/scripts/build-docs.js +523 -0
- package/src/app.d.ts +13 -0
- package/src/app.html +11 -0
- package/src/lib/components/Editor.svelte +164 -0
- package/src/lib/components/Player.svelte +529 -0
- package/src/lib/components/Preview.svelte +496 -0
- package/src/lib/index.ts +8 -0
- package/src/lib/lilylet/highlight.ts +179 -0
- package/src/lib/lilylet/index.ts +93 -0
- package/src/lib/stores/editor.ts +119 -0
- package/src/lib/utils/share.ts +68 -0
- package/src/lib/verovio/toolkit.ts +83 -0
- package/src/modules.d.ts +41 -0
- package/src/routes/+layout.svelte +8 -0
- package/src/routes/+layout.ts +3 -0
- package/src/routes/+page.svelte +596 -0
- package/src/routes/markdown/+page.svelte +980 -0
- package/static/docs/lilylet-tutorial.md +932 -0
- package/static/js/.gitkeep +7 -0
- package/static/js/musicWidgetsBrowser.umd.min.js +2 -0
- package/static/soundfont/acoustic_grand_piano-mp3.js +93 -0
- package/static/soundfont/acoustic_grand_piano-ogg.js +93 -0
- package/svelte.config.js +22 -0
- package/tsconfig.json +15 -0
- package/vite.config.ts +15 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Lilylet conversion utilities
|
|
2
|
+
import * as lilylet from '@k-l-lambda/lilylet';
|
|
3
|
+
|
|
4
|
+
export type ConversionResult = {
|
|
5
|
+
success: true;
|
|
6
|
+
data: string;
|
|
7
|
+
} | {
|
|
8
|
+
success: false;
|
|
9
|
+
error: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type MEIResult = {
|
|
13
|
+
success: true;
|
|
14
|
+
mei: string;
|
|
15
|
+
measureCount: number;
|
|
16
|
+
staffCount: number;
|
|
17
|
+
} | {
|
|
18
|
+
success: false;
|
|
19
|
+
error: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Convert Lilylet code to MEI XML
|
|
24
|
+
*/
|
|
25
|
+
export function lilyletToMEI(code: string): MEIResult {
|
|
26
|
+
try {
|
|
27
|
+
// Parse Lilylet code to LilyletDoc
|
|
28
|
+
const doc = lilylet.parseCode(code);
|
|
29
|
+
|
|
30
|
+
// Encode to MEI XML
|
|
31
|
+
const mei = lilylet.meiEncoder.encode(doc);
|
|
32
|
+
|
|
33
|
+
// Calculate total staff count
|
|
34
|
+
let staffCount = 1;
|
|
35
|
+
if (doc.measures && doc.measures.length > 0) {
|
|
36
|
+
const firstMeasure = doc.measures[0];
|
|
37
|
+
staffCount = firstMeasure.parts.reduce((total, part) => {
|
|
38
|
+
const maxStaff = part.voices.reduce((max, voice) => Math.max(max, voice.staff || 1), 1);
|
|
39
|
+
return total + maxStaff;
|
|
40
|
+
}, 0) || 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
success: true,
|
|
45
|
+
mei,
|
|
46
|
+
measureCount: doc.measures?.length || 1,
|
|
47
|
+
staffCount
|
|
48
|
+
};
|
|
49
|
+
} catch (error) {
|
|
50
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
51
|
+
console.error('Lilylet parsing error:', error);
|
|
52
|
+
return { success: false, error: errorMessage };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Convert MusicXML to Lilylet code
|
|
58
|
+
*/
|
|
59
|
+
export function musicXmlToLilylet(xml: string): ConversionResult {
|
|
60
|
+
try {
|
|
61
|
+
const doc = lilylet.musicXmlDecoder.decode(xml);
|
|
62
|
+
const code = lilylet.serializeLilyletDoc(doc);
|
|
63
|
+
return { success: true, data: code };
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
66
|
+
console.error('MusicXML conversion error:', error);
|
|
67
|
+
return { success: false, error: `MusicXML conversion failed: ${errorMessage}` };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Convert LilyPond to Lilylet code
|
|
73
|
+
* Note: lilypondDecoder requires optional @k-l-lambda/lotus dependency
|
|
74
|
+
*/
|
|
75
|
+
export function lilypondToLilylet(source: string): ConversionResult {
|
|
76
|
+
try {
|
|
77
|
+
// lilypondDecoder is not included in the main lilylet export
|
|
78
|
+
// It requires the optional @k-l-lambda/lotus dependency
|
|
79
|
+
const lilypondDecoder = (lilylet as any).lilypondDecoder;
|
|
80
|
+
if (!lilypondDecoder) {
|
|
81
|
+
return { success: false, error: 'LilyPond decoder not available (requires @k-l-lambda/lotus)' };
|
|
82
|
+
}
|
|
83
|
+
const doc = lilypondDecoder.decode(source);
|
|
84
|
+
const code = lilylet.serializeLilyletDoc(doc);
|
|
85
|
+
return { success: true, data: code };
|
|
86
|
+
} catch (error) {
|
|
87
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
88
|
+
console.error('LilyPond conversion error:', error);
|
|
89
|
+
return { success: false, error: `LilyPond conversion failed: ${errorMessage}` };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export { lilylet } from './highlight';
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { writable, derived, type Writable, type Readable } from 'svelte/store';
|
|
2
|
+
import { browser } from '$app/environment';
|
|
3
|
+
|
|
4
|
+
const STORAGE_KEY = 'lilylet-editor-code';
|
|
5
|
+
|
|
6
|
+
export interface LogEntry {
|
|
7
|
+
timestamp: Date;
|
|
8
|
+
level: 'info' | 'warning' | 'error';
|
|
9
|
+
message: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface EditorState {
|
|
13
|
+
code: string;
|
|
14
|
+
error: string | null;
|
|
15
|
+
mei: string | null;
|
|
16
|
+
svg: string | null;
|
|
17
|
+
pageCount: number;
|
|
18
|
+
isRendering: boolean;
|
|
19
|
+
verovioReady: boolean;
|
|
20
|
+
cursorElementId: string | null;
|
|
21
|
+
previewWidth: number;
|
|
22
|
+
logs: LogEntry[];
|
|
23
|
+
logsExpanded: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Sample Lilylet code demonstrating basic syntax
|
|
27
|
+
const defaultCode = `[title "Jesu, meine Freude"]
|
|
28
|
+
[subtitle "BWV 610"]
|
|
29
|
+
[composer "J.S. Bach"]
|
|
30
|
+
|
|
31
|
+
\\staff "1" \\key c \\minor \\time 4/4 \\clef "treble" \\stemUp g'4 g f ef \\\\
|
|
32
|
+
\\staff "1" \\stemDown ef16[ d ef8]~ ef16[ f ef d] c8[ d]~ d[ c] \\\\
|
|
33
|
+
\\staff "2" \\clef "bass" c16[ b c8]~ c16[ b c g] a8[ g]~ g16[ g af ef] \\\\
|
|
34
|
+
\\staff "3" \\clef "bass" r8 c,16[ d] ef[ d ef8]~ ef16[ a, b g] c[ b c8] | % 1
|
|
35
|
+
|
|
36
|
+
\\staff "1" \\stemUp d2 c\\fermata \\\\
|
|
37
|
+
\\staff "1" \\stemDown c8[ c4 b8] c8.[ \\staff "2" \\stemUp g16] \\staff "1" c[ b c d] \\\\
|
|
38
|
+
\\staff "2" f,16[ ef f d] g[ af g f] ef[ d ef8]~ ef16[ f ef d] \\\\
|
|
39
|
+
\\staff "3" r16 g,[ af f] g[ f g8] c,2 | % 2
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
// Load saved code from localStorage (browser only)
|
|
43
|
+
function loadSavedCode(): string {
|
|
44
|
+
if (browser) {
|
|
45
|
+
const saved = localStorage.getItem(STORAGE_KEY);
|
|
46
|
+
if (saved) return saved;
|
|
47
|
+
}
|
|
48
|
+
return defaultCode;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Save code to localStorage (browser only)
|
|
52
|
+
function saveCode(code: string): void {
|
|
53
|
+
if (browser) {
|
|
54
|
+
localStorage.setItem(STORAGE_KEY, code);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const initialState: EditorState = {
|
|
59
|
+
code: defaultCode, // Will be updated in browser
|
|
60
|
+
error: null,
|
|
61
|
+
mei: null,
|
|
62
|
+
svg: null,
|
|
63
|
+
pageCount: 0,
|
|
64
|
+
isRendering: false,
|
|
65
|
+
verovioReady: false,
|
|
66
|
+
cursorElementId: null,
|
|
67
|
+
previewWidth: 800,
|
|
68
|
+
logs: [],
|
|
69
|
+
logsExpanded: false
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function createEditorStore() {
|
|
73
|
+
const { subscribe, set, update }: Writable<EditorState> = writable(initialState);
|
|
74
|
+
|
|
75
|
+
// Load saved code in browser
|
|
76
|
+
if (browser) {
|
|
77
|
+
const savedCode = loadSavedCode();
|
|
78
|
+
if (savedCode !== defaultCode) {
|
|
79
|
+
update((s) => ({ ...s, code: savedCode }));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
subscribe,
|
|
85
|
+
setCode: (code: string) => {
|
|
86
|
+
saveCode(code);
|
|
87
|
+
update((s) => ({ ...s, code }));
|
|
88
|
+
},
|
|
89
|
+
setMEI: (mei: string) => update((s) => ({ ...s, mei })),
|
|
90
|
+
setSVG: (svg: string, pageCount: number) =>
|
|
91
|
+
update((s) => ({ ...s, svg, pageCount, error: null })),
|
|
92
|
+
setError: (error: string) => update((s) => ({ ...s, error, svg: null })),
|
|
93
|
+
setRendering: (isRendering: boolean) => update((s) => ({ ...s, isRendering })),
|
|
94
|
+
setVerovioReady: (verovioReady: boolean) => update((s) => ({ ...s, verovioReady })),
|
|
95
|
+
setCursorElement: (cursorElementId: string | null) => update((s) => ({ ...s, cursorElementId })),
|
|
96
|
+
setPreviewWidth: (previewWidth: number) => update((s) => ({ ...s, previewWidth })),
|
|
97
|
+
addLog: (level: LogEntry['level'], message: string) => update((s) => {
|
|
98
|
+
const newLog: LogEntry = { timestamp: new Date(), level, message };
|
|
99
|
+
const newLogs = [...s.logs, newLog].slice(-100); // Keep last 100 logs
|
|
100
|
+
// Auto-expand on error
|
|
101
|
+
const shouldExpand = level === 'error' ? true : s.logsExpanded;
|
|
102
|
+
return { ...s, logs: newLogs, logsExpanded: shouldExpand };
|
|
103
|
+
}),
|
|
104
|
+
setLogsExpanded: (expanded: boolean) => update((s) => ({ ...s, logsExpanded: expanded })),
|
|
105
|
+
clearLogs: () => update((s) => ({ ...s, logs: [] })),
|
|
106
|
+
reset: () => set(initialState)
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const editorStore = createEditorStore();
|
|
111
|
+
|
|
112
|
+
// Derived store for checking if we have valid output
|
|
113
|
+
export const hasOutput: Readable<boolean> = derived(editorStore, ($state) => $state.svg !== null);
|
|
114
|
+
|
|
115
|
+
// Derived store for checking if there's an error
|
|
116
|
+
export const hasError: Readable<boolean> = derived(
|
|
117
|
+
editorStore,
|
|
118
|
+
($state) => $state.error !== null
|
|
119
|
+
);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// URL sharing utilities with pako compression
|
|
2
|
+
import pako from 'pako';
|
|
3
|
+
import { Base64 } from 'js-base64';
|
|
4
|
+
|
|
5
|
+
export interface ShareState {
|
|
6
|
+
code: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Encode the editor state into a compressed, URL-safe string
|
|
11
|
+
*/
|
|
12
|
+
export function encodeState(state: ShareState): string {
|
|
13
|
+
const json = JSON.stringify(state);
|
|
14
|
+
const compressed = pako.deflate(json, { level: 9 });
|
|
15
|
+
return Base64.fromUint8Array(compressed, true); // URL-safe base64
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Decode a compressed, URL-safe string back into editor state
|
|
20
|
+
*/
|
|
21
|
+
export function decodeState(encoded: string): ShareState | null {
|
|
22
|
+
try {
|
|
23
|
+
const compressed = Base64.toUint8Array(encoded);
|
|
24
|
+
const json = pako.inflate(compressed, { to: 'string' });
|
|
25
|
+
return JSON.parse(json);
|
|
26
|
+
} catch (e) {
|
|
27
|
+
console.error('Failed to decode state:', e);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get the current URL with the encoded state
|
|
34
|
+
*/
|
|
35
|
+
export function getShareUrl(state: ShareState): string {
|
|
36
|
+
const encoded = encodeState(state);
|
|
37
|
+
const url = new URL(window.location.href);
|
|
38
|
+
url.searchParams.set('code', encoded);
|
|
39
|
+
return url.toString();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse state from the current URL
|
|
44
|
+
*/
|
|
45
|
+
export function getStateFromUrl(): ShareState | null {
|
|
46
|
+
if (typeof window === 'undefined') return null;
|
|
47
|
+
|
|
48
|
+
const params = new URLSearchParams(window.location.search);
|
|
49
|
+
const encoded = params.get('code');
|
|
50
|
+
|
|
51
|
+
if (!encoded) return null;
|
|
52
|
+
|
|
53
|
+
return decodeState(encoded);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Copy the share URL to clipboard
|
|
58
|
+
*/
|
|
59
|
+
export async function copyShareUrl(state: ShareState): Promise<boolean> {
|
|
60
|
+
try {
|
|
61
|
+
const url = getShareUrl(state);
|
|
62
|
+
await navigator.clipboard.writeText(url);
|
|
63
|
+
return true;
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.error('Failed to copy URL:', e);
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Verovio Toolkit wrapper for SvelteKit
|
|
2
|
+
import type { VerovioToolkit } from 'verovio';
|
|
3
|
+
|
|
4
|
+
let toolkit: VerovioToolkit | null = null;
|
|
5
|
+
let initPromise: Promise<VerovioToolkit> | null = null;
|
|
6
|
+
|
|
7
|
+
export interface RenderOptions {
|
|
8
|
+
pageWidth?: number;
|
|
9
|
+
pageHeight?: number;
|
|
10
|
+
scale?: number;
|
|
11
|
+
adjustPageHeight?: boolean;
|
|
12
|
+
breaks?: 'auto' | 'none' | 'encoded';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const defaultOptions: RenderOptions = {
|
|
16
|
+
pageWidth: 2100,
|
|
17
|
+
pageHeight: 2970,
|
|
18
|
+
scale: 40,
|
|
19
|
+
adjustPageHeight: true,
|
|
20
|
+
breaks: 'auto'
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export async function initVerovio(): Promise<VerovioToolkit> {
|
|
24
|
+
if (toolkit) return toolkit;
|
|
25
|
+
|
|
26
|
+
if (initPromise) return initPromise;
|
|
27
|
+
|
|
28
|
+
initPromise = (async () => {
|
|
29
|
+
// Import the WASM module and toolkit separately for browser compatibility
|
|
30
|
+
const createVerovioModule = (await import('verovio/wasm')).default;
|
|
31
|
+
const { VerovioToolkit } = await import('verovio/esm');
|
|
32
|
+
|
|
33
|
+
// Initialize the WASM module
|
|
34
|
+
const VerovioModule = await createVerovioModule();
|
|
35
|
+
|
|
36
|
+
toolkit = new VerovioToolkit(VerovioModule);
|
|
37
|
+
toolkit.setOptions(defaultOptions);
|
|
38
|
+
|
|
39
|
+
console.log('Verovio initialized:', toolkit.getVersion());
|
|
40
|
+
return toolkit;
|
|
41
|
+
})();
|
|
42
|
+
|
|
43
|
+
return initPromise;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getToolkit(): VerovioToolkit | null {
|
|
47
|
+
return toolkit;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function renderMEI(
|
|
51
|
+
mei: string,
|
|
52
|
+
options?: Partial<RenderOptions>
|
|
53
|
+
): Promise<{ svg: string; pageCount: number; midi?: string }> {
|
|
54
|
+
const tk = await initVerovio();
|
|
55
|
+
|
|
56
|
+
if (options) {
|
|
57
|
+
tk.setOptions({ ...defaultOptions, ...options });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const success = tk.loadData(mei);
|
|
61
|
+
if (!success) {
|
|
62
|
+
throw new Error('Failed to load MEI data');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const pageCount = tk.getPageCount();
|
|
66
|
+
const svg = tk.renderToSVG(1);
|
|
67
|
+
|
|
68
|
+
return { svg, pageCount };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function renderToMIDI(mei: string): Promise<string> {
|
|
72
|
+
const tk = await initVerovio();
|
|
73
|
+
tk.loadData(mei);
|
|
74
|
+
return tk.renderToMIDI();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function getElementsAtTime(millisec: number): Promise<{
|
|
78
|
+
notes: string[];
|
|
79
|
+
page: number;
|
|
80
|
+
}> {
|
|
81
|
+
const tk = await initVerovio();
|
|
82
|
+
return tk.getElementsAtTime(millisec);
|
|
83
|
+
}
|
package/src/modules.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Module declarations for packages without types
|
|
2
|
+
declare module 'verovio' {
|
|
3
|
+
export interface VerovioToolkit {
|
|
4
|
+
loadData(data: string): boolean;
|
|
5
|
+
getPageCount(): number;
|
|
6
|
+
renderToSVG(page: number): string;
|
|
7
|
+
renderToMIDI(): string;
|
|
8
|
+
getElementsAtTime(millisec: number): { notes: string[]; page: number };
|
|
9
|
+
setOptions(options: any): void;
|
|
10
|
+
getVersion(): string;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
declare module 'verovio/wasm' {
|
|
15
|
+
const createVerovioModule: () => Promise<any>;
|
|
16
|
+
export default createVerovioModule;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
declare module 'verovio/esm' {
|
|
20
|
+
import type { VerovioToolkit as VT } from 'verovio';
|
|
21
|
+
export class VerovioToolkit implements VT {
|
|
22
|
+
constructor(module: any);
|
|
23
|
+
loadData(data: string): boolean;
|
|
24
|
+
getPageCount(): number;
|
|
25
|
+
renderToSVG(page: number): string;
|
|
26
|
+
renderToMIDI(): string;
|
|
27
|
+
getElementsAtTime(millisec: number): { notes: string[]; page: number };
|
|
28
|
+
setOptions(options: any): void;
|
|
29
|
+
getVersion(): string;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
declare module '@k-l-lambda/music-widgets/dist/musicWidgetsBrowser.es.js' {
|
|
34
|
+
export const MidiAudio: {
|
|
35
|
+
loadPlugin(options: { soundfontUrl: string; api: string }): Promise<void>;
|
|
36
|
+
noteOn(channel: number, note: number | undefined, velocity: number | undefined, timestamp: number): void;
|
|
37
|
+
noteOff(channel: number, note: number | undefined, timestamp: number): void;
|
|
38
|
+
programChange(channel: number, program: number | undefined): void;
|
|
39
|
+
stopAllNotes?: () => void;
|
|
40
|
+
};
|
|
41
|
+
}
|