Ho capito
Originale tetragramma
Seleziona
+ Nota
+ Divisio
+ ♭
+ Chiave
−
Adatta
+
Trascina qui una partitura gregoriana
PDF, JPG, PNG, TIFF o BMP con notazione quadrata su tetragramma. L'app riconosce rigo, chiavi, neumi e testo; tu correggi sull'immagine e scarichi la versione su pentagramma. Puoi caricare anche un file GABC (Gregorio, GregoBase).
Carica partitura
Non hai una partitura sotto mano? Guarda un esempio : il Salve Regina in tono semplice.
Trascrizione pentagramma
▶ Ascolta
Trasp.
A− A+
{ const d = e.data; try { const p = GR.REC.recognizePage(d.gray, d.w, d.h, d.opts);
self.postMessage({ id: d.id, page: { w: p.w, h: p.h, skew: p.skew, thick: p.thick, space: p.space, head: p.head, staves: p.staves, fiveLines: p.fiveLines }, gray: p.gray }, [p.gray.buffer]);
} catch (err) { self.postMessage({ id: d.id, error: String(err && err.stack || err) }); } };`;
recWorker = new Worker(URL.createObjectURL(new Blob([src], { type: 'application/javascript' })));
recWorker.onmessage = e => { const cb = recCbs[e.data.id]; delete recCbs[e.data.id]; cb && cb(e.data); };
return recWorker;
}
function recognize(gray, w, h, opts) {
return new Promise((res, rej) => {
const id = ++recSeq; recCbs[id] = d => d.error ? rej(new Error(d.error)) : res(d);
try { getRecWorker().postMessage({ id, gray, w, h, opts }, [gray.buffer]); }
catch (e) { // ripiego senza worker
delete recCbs[id];
try { const p = R.recognizePage(gray, w, h, opts); res({ page: p, gray: p.gray }); } catch (err) { rej(err); }
}
});
}
// ---------- OCR (Tesseract, tutto incorporato) ----------
let tessP = null;
function getTess() {
if (tessP) return tessP;
tessP = (async () => {
const t = id => document.getElementById(id).textContent.split('<_/script').join('=0)return Promise.resolve(new Response(u.slice(0)));return f.apply(self,arguments);};})();\n`;
const code = shim + t('tess-core') + '\n' + t('tess-worker');
const url = URL.createObjectURL(new Blob([code], { type: 'application/javascript' }));
const worker = await Tesseract.createWorker('lat', 1, { workerPath: url, workerBlobURL: false, corePath: 'incorporato', langPath: 'https://incorporato.invalid/lang', cacheMethod: 'none', gzip: true });
let psm = null;
return {
async recognize(img, mode) {
if (psm !== mode) { await worker.setParameters({ tessedit_pageseg_mode: String(mode), preserve_interword_spaces: '1' }); psm = mode; }
const c = document.createElement('canvas'); c.width = img.w; c.height = img.h;
const ctx = c.getContext('2d'), id = ctx.createImageData(img.w, img.h);
for (let i = 0, j = 0; i < img.data.length; i++, j += 4) { id.data[j] = id.data[j + 1] = id.data[j + 2] = img.data[i]; id.data[j + 3] = 255; }
ctx.putImageData(id, 0, 0);
const r = await worker.recognize(c, {}, { blocks: true, text: true });
const words = [];
(r.data.blocks || []).forEach(b => b.paragraphs.forEach(p => p.lines.forEach(l => l.words.forEach(w => words.push(w)))));
return { words, text: r.data.text };
}
};
})();
tessP.catch(() => { tessP = null; });
return tessP;
}
// ---------- apertura file ----------
function grayFromRGBA(rgba, w, h) {
const g = R.toGray(rgba, w, h);
if (S.settings.ignoreRed) for (let i = 0, j = 0; i < g.length; i++, j += 4) { const r = rgba[j], gg = rgba[j + 1], b = rgba[j + 2]; if (r > 110 && r - Math.max(gg, b) > 55) g[i] = 255; }
return g;
}
function limitSize(w, h) { const MAX = 5200; const k = Math.min(1, MAX / Math.max(w, h)); return k; }
async function rasterFromBitmap(bmp) {
const k = limitSize(bmp.width, bmp.height), w = Math.round(bmp.width * k), h = Math.round(bmp.height * k);
const c = document.createElement('canvas'); c.width = w; c.height = h;
const ctx = c.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, w, h); ctx.drawImage(bmp, 0, 0, w, h);
return { w, h, rgba: ctx.getImageData(0, 0, w, h).data };
}
async function loadImageBlob(blob) {
if (window.createImageBitmap) { try { return await rasterFromBitmap(await createImageBitmap(blob)); } catch (e) {} }
const url = URL.createObjectURL(blob);
try {
const img = await new Promise((res, rej) => { const i = new Image(); i.onload = () => res(i); i.onerror = () => rej(new Error('Immagine non leggibile')); i.src = url; });
return await rasterFromBitmap(img);
} finally { URL.revokeObjectURL(url); }
}
let pdfReady = false;
function initPdf() {
if (pdfReady) return;
const src = document.getElementById('pdf-worker').textContent.split('<_/script').join('= 5 ? R.groupStaves(R.findLines(b1, c1.width, c1.height, m1.thick, m1.space), m1.space) : [];
const gaps = st1.map(s => (s.ys[0] - s.ys[3]) / 3).sort((a, b) => a - b);
const sp1 = gaps.length ? gaps[gaps.length >> 1] : (m1 ? m1.space : 0);
if (sp1 >= 5 && sp1 <= 80) dpi = Math.max(150, Math.min(600, Math.round(D0 * 34 / sp1)));
console.log('Peregrinus: interlinea a ' + D0 + ' dpi = ' + sp1.toFixed(2) + ' px, lettura a ' + dpi + ' dpi');
} catch (e) { console.warn('Risoluzione automatica non riuscita', e); }
}
let scale = dpi / 72; const vp0 = pg.getViewport({ scale: 1 });
scale = Math.min(scale, 5200 / Math.max(vp0.width, vp0.height));
const vp = pg.getViewport({ scale });
const c = document.createElement('canvas'); c.width = Math.round(vp.width); c.height = Math.round(vp.height);
const ctx = c.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, c.width, c.height);
await pg.render({ canvasContext: ctx, viewport: vp }).promise;
const rgba = ctx.getImageData(0, 0, c.width, c.height).data; if (window.__pgImgHook) window.__pgImgHook(rgba, c.width, c.height);
let rgbaText = null;
if (pdfT) {
ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, c.width, c.height);
await (await pdfT.getPage(i)).render({ canvasContext: ctx, viewport: vp }).promise;
rgbaText = ctx.getImageData(0, 0, c.width, c.height).data;
}
await onPage({ w: c.width, h: c.height, rgba, rgbaText, label: file.name + ' · p. ' + i }, i, pdf.numPages);
}
return;
}
if (/\.tiff?$/.test(name) || file.type === 'image/tiff') {
const ifds = UTIF.decode(buf);
const pages = ifds.filter(f => f.t256);
for (let i = 0; i < pages.length; i++) {
UTIF.decodeImage(buf, pages[i]);
const rgba = UTIF.toRGBA8(pages[i]), w = pages[i].width, h = pages[i].height;
const c = document.createElement('canvas'); c.width = w; c.height = h;
c.getContext('2d').putImageData(new ImageData(new Uint8ClampedArray(rgba.buffer, rgba.byteOffset, w * h * 4), w, h), 0, 0);
const r = await rasterFromBitmap(c);
await onPage(Object.assign(r, { label: file.name + (pages.length > 1 ? ' · p. ' + (i + 1) : '') }), i + 1, pages.length);
}
return;
}
const r = await loadImageBlob(new Blob([buf], { type: file.type || 'image/' + name.split('.').pop() }));
await onPage(Object.assign(r, { label: file.name }), 1, 1);
}
// ---------- statistiche d'uso (solo quando l'app è servita dal plugin del sito) ----------
// statistiche del plugin WordPress: attive se il sito le ha accese (indirizzo /app/ oppure ?peregrinus=app)
const STATS_ON = window.PEREGRINUS_SITE ? window.PEREGRINUS_SITE.stats !== false : /[?&](peregrinus|tetra_penta)=app\b/.test(location.search);
const SESSION = Math.random().toString(36).slice(2, 12);
function incipit(maxSyl) {
const out = []; let word = '';
for (const p of S.pages) for (const st of (p.staves || [])) for (const it of st.items) {
if (it.kind !== 'note' || !it.syl) continue;
word += it.syl.text.replace(/[.,;:!?*]+$/g, '');
if (!it.syl.hyph) { out.push(word); word = ''; }
if (!it.syl.hyph && out.length >= (maxSyl || 5)) return out.join(' ').slice(0, 160);
}
if (word) out.push(word);
return out.join(' ').slice(0, 160);
}
function contaNote() { let n = 0, sy = 0, st = 0; S.pages.forEach(p => (p.staves || []).forEach(x => { st++; x.items.forEach(i => { if (i.kind === 'note') { n++; if (i.syl) sy++; } }); })); return { note: n, sillabe: sy, righi: st }; }
function track(evento, dati) {
// sito peregrinus.app: conteggi anonimi (solo il tipo di evento, nessun titolo o dato)
if (window.PEREGRINUS_TRACK && !S.isSample) { try { window.PEREGRINUS_TRACK(evento, dati && dati.formato); } catch (e) {} }
if (!STATS_ON || S.isSample) return;
try {
const body = new URLSearchParams(Object.assign({ evento, sessione: SESSION }, dati || {}));
fetch(location.pathname + '?peregrinus=evento', { method: 'POST', body, keepalive: true, credentials: 'same-origin' }).catch(() => {});
} catch (e) {}
}
async function openFiles(files, append, isSample) {
if (!files || !files.length || S.busy) return;
S.busy = true;
S.isSample = !!isSample;
const t0 = performance.now();
try {
if (!append) { S.pages = []; S.undo = []; S.redo = []; S.sel = null; S.lastFiles = files.slice(); } else S.lastFiles = (S.lastFiles || []).concat(files);
const first = files[0].name.replace(/\.[^.]+$/, '');
if (!append) { S.fileBase = first.replace(/[^\w\-àèéìòù ]+/gi, '_') || 'gregoriano'; S.settings.title = first; $('setTitle').value = first; }
$('srcErr').hidden = true;
const errs = [];
for (const f of files) {
if (!/\.(pdf|png|jpe?g|tiff?|bmp|gabc)$/i.test(f.name) && !/^image\/|^application\/pdf$/.test(f.type)) {
errs.push(T('{0}: formato non supportato. Carica un PDF, un\'immagine (JPG, PNG, TIFF, BMP) o un file GABC.', f.name)); continue;
}
msg(T('Lettura di {0}…', f.name), 0.02);
if (/\.gabc$/i.test(f.name)) { const e = await importGabc(f, !append && files.length === 1); if (e) errs.push(e); continue; }
const mine = [];
await rastersFromFile(f, async (r, i, n) => {
const page = { label: r.label, w: r.w, h: r.h, staves: [], status: 'busy', raw: r.rgba, rawText: r.rgbaText || null };
S.pages.push(page); mine.push(page);
if (S.pages.length === 1) S.cur = 0;
renderTabs();
await analyzePage(page, T('{0}: pagina {1} di {2}', f.name, i, n));
});
// pagine senza tetragramma: si tolgono; se non ne resta nessuna il file non è una partitura gregoriana
const bad = mine.filter(p => p.reject);
if (bad.length) {
S.pages = S.pages.filter(p => !p.reject);
const penta = bad.some(p => p.reject === 'penta');
const what = bad.length === mine.length ? f.name : T('{0}, {1} pagine su {2}', f.name, bad.length, mine.length);
errs.push(penta
? T('{0}: è una partitura su pentagramma (cinque righe). Peregrinus© legge la notazione quadrata gregoriana su tetragramma (quattro righe) e i file GABC.', what)
: T('{0}: non ho trovato un tetragramma. Peregrinus© legge partiture gregoriane in notazione quadrata su quattro righe (PDF o immagini) e i file GABC.', what));
}
}
if (S.cur >= S.pages.length) S.cur = Math.max(0, S.pages.length - 1);
renderTabs(); showPage(S.cur);
afterChange(true); updateStats();
if (errs.length) { fileErr(errs.join(' ')); track('errore', { file: files.map(f => f.name).join(', ').slice(0, 200), messaggio: errs.join(' ').slice(0, 200) }); }
if (!S.pages.length) return;
const c = contaNote();
track('partitura', { titolo: S.settings.title || '', incipit: incipit(), file: files.map(f => f.name).join(', ').slice(0, 200),
tipo: files.map(f => (f.name.split('.').pop() || '').toLowerCase()).join(','), pagine: S.pages.length, righi: c.righi, note: c.note, sillabe: c.sillabe,
ocr: S.settings.ocr ? 1 : 0, durata_ms: Math.round(performance.now() - t0) });
} catch (e) { errMsg(e); track('errore', { file: files.map(f => f.name).join(', ').slice(0, 200), messaggio: String(e && e.message || e).slice(0, 200) }); }
finally { S.busy = false; }
}
// File GABC (Gregorio, GregoBase): note e testo arrivano dal file, niente riconoscimento né OCR.
// La pagina «originale» è disegnata in notazione quadrata essenziale e si corregge come quella di un PDF.
async function importGabc(f, setTitle) {
const txt = await f.text();
const sp = 34, mc = document.createElement('canvas').getContext('2d'); mc.font = `${sp * 0.95}px "PG Garamond", Georgia, serif`;
const doc = GR.GABC.toDoc(txt, { newId: () => 'q' + R.newId(), measure: t => mc.measureText(t).width });
if (!doc.pages.length) return T('{0}: nel file GABC non ci sono note.', f.name);
if (setTitle && doc.title) { S.settings.title = doc.title; $('setTitle').value = doc.title; }
doc.pages.forEach((page, i) => {
page.label = f.name + (doc.pages.length > 1 ? ' · p. ' + (i + 1) : '');
const c = document.createElement('canvas'); c.width = page.w; c.height = page.h;
GR.GABC.drawPage(page, c.getContext('2d'), { font: '"PG Garamond", Georgia, serif', title: i === 0 ? doc.title : '', mode: i === 0 ? doc.mode : null });
page.canvas = c;
GR.EU.detect(page, S.settings.euouae);
S.pages.push(page);
});
if (S.pages.length === doc.pages.length) S.cur = 0;
renderTabs(); showPage(S.cur);
const nn = doc.pages.reduce((a, p) => a + p.staves.reduce((b, st) => b + st.items.filter(it => it.kind === 'note').length, 0), 0);
const ns = doc.pages.reduce((a, p) => a + p.staves.length, 0);
msg(T('{0}: importato dal GABC: {1} righi, {2} note, testo letto dal file. Controlla e correggi sull\'immagine.', f.name, ns, nn));
scheduleRender();
}
async function analyzePage(page, label) {
msg(T('Riconoscimento della notazione — {0}…', label), 0.3);
const gray = grayFromRGBA(page.raw, page.w, page.h);
const t0 = performance.now();
const res = await recognize(gray, page.w, page.h, { above: +S.settings.above });
Object.assign(page, res.page); page.gray = res.gray;
page.canvas = grayCanvas(page.gray, page.w, page.h);
page.status = 'ok';
if (S.pages[S.cur] === page) showPage(S.cur);
renderTabs();
const nn = page.staves.reduce((a, s) => a + s.items.filter(i => i.kind === 'note').length, 0);
// controllo: ci vuole almeno un tetragramma, e non un pentagramma moderno
if (!page.staves.length || page.fiveLines >= Math.max(1, page.staves.length / 2)) {
page.reject = page.staves.length ? 'penta' : 'none'; page.staves = []; delete page.raw; delete page.rawText; return;
}
msg(T('{0}: {1} righi, {2} note ({3} ms).', label, page.staves.length, nn, Math.round(performance.now() - t0)));
if (S.settings.ocr && page.staves.length) {
try {
msg('Preparazione del riconoscimento del testo (la prima volta richiede qualche secondo)…', 0.6);
const tess = await getTess();
// testo dalla resa con i caratteri (se c'è), raddrizzata come la pagina della notazione
const g0 = page.gray;
if (page.rawText) { let tg = grayFromRGBA(page.rawText, page.w, page.h); if (page.skew) tg = R.rotateGray(tg, page.w, page.h, page.skew); page.gray = tg; }
try { await GR.OCR.ocrPage(page, tess, (i, n) => msg(T('Lettura del testo latino — {0}, rigo {1} di {2}…', label, i + 1, n), 0.6 + 0.4 * i / n)); }
finally { page.gray = g0; }
page.ocr = true;
const ne = GR.EU.detect(page, S.settings.euouae);
msg(T('{0}: {1} righi, {2} note, testo letto. Controlla e correggi sull\'immagine.', label, page.staves.length, nn) + (ne ? ' ' + (ne > 1 ? T('Trovati {0} Euouae: clicca sull\'Euouae per scegliere se inserire il Gloria Patri completo.', ne) : T('Trovato un Euouae: clicca sull\'Euouae per scegliere se inserire il Gloria Patri completo.')) : ''));
} catch (e) { console.error(e); msg(T('Testo non riconosciuto ({0}). Puoi inserire le sillabe a mano.', e.message || e)); }
}
delete page.raw; delete page.rawText;
if (S.pages[S.cur] === page) { drawOverlay(); renderInspector(); }
scheduleRender();
}
function grayCanvas(g, w, h) {
const c = document.createElement('canvas'); c.width = w; c.height = h;
const ctx = c.getContext('2d'), id = ctx.createImageData(w, h);
for (let i = 0, j = 0; i < g.length; i++, j += 4) { id.data[j] = id.data[j + 1] = id.data[j + 2] = g[i]; id.data[j + 3] = 255; }
ctx.putImageData(id, 0, 0); return c;
}
// ---------- vista dell'originale ----------
function renderTabs() {
const el = $('pageTabs'); el.innerHTML = '';
if (S.pages.length <= 1) return;
S.pages.forEach((p, i) => {
const b = document.createElement('button'); b.className = 'tab' + (i === S.cur ? ' on' : '');
b.innerHTML = ` ${i + 1}`; b.title = p.label;
b.onclick = () => { S.sel = null; showPage(i); };
el.appendChild(b);
});
}
function showPage(i) {
S.cur = i; const p = S.pages[i]; renderTabs();
if (!p || !p.canvas) { $('stage').hidden = true; $('srcEmpty').hidden = !!p; return; }
$('srcEmpty').hidden = true; $('stage').hidden = false;
const c = $('srcCanvas'); c.width = p.w; c.height = p.h; c.getContext('2d').drawImage(p.canvas, 0, 0);
$('overlay').setAttribute('viewBox', `0 0 ${p.w} ${p.h}`);
applyZoom(); drawOverlay(); renderInspector();
}
function applyZoom() {
const p = S.pages[S.cur]; if (!p) return;
const vw = $('srcView').clientWidth - 28;
const fit = vw / p.w;
const z = S.zoom ? fit * S.zoom : fit;
$('stage').style.width = Math.round(p.w * z) + 'px';
}
$('zoomIn').onclick = () => { S.zoom = (S.zoom || 1) * 1.25; applyZoom(); };
$('zoomOut').onclick = () => { S.zoom = Math.max(0.25, (S.zoom || 1) / 1.25); applyZoom(); };
$('zoomFit').onclick = () => { S.zoom = 0; applyZoom(); };
window.addEventListener('resize', () => { applyZoom(); scheduleRender(); });
const SVGNS = 'http://www.w3.org/2000/svg';
function el(tag, attrs, parent, text) { const e = document.createElementNS(SVGNS, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); if (text != null) e.textContent = text; if (parent) parent.appendChild(e); return e; }
function stepName(step, clef) { const d = M.diatonic(step, clef); return I18N.note(['Do', 'Re', 'Mi', 'Fa', 'Sol', 'La', 'Si'][((d % 7) + 7) % 7]); }
function drawOverlay() {
const svg = $('overlay'); svg.innerHTML = '';
const p = S.pages[S.cur]; if (!p || !p.staves) return;
const sp = p.space || 20;
const g0 = el('g', {}, svg);
p.staves.forEach((st, si) => {
const g = el('g', { 'data-staff': si, opacity: st.skip ? 0.35 : 1 }, g0);
const selStaff = S.sel && S.sel.staff === si && S.sel.kind === 'staff';
for (let k = 0; k < 4; k++) el('line', { x1: st.x0, y1: R.lineY(st, k, st.x0), x2: st.x1, y2: R.lineY(st, k, st.x1), stroke: selStaff ? 'var(--accent)' : 'var(--overlay-staff)', 'stroke-width': Math.max(1, sp * (selStaff ? 0.09 : 0.05)), 'pointer-events': 'none' }, g);
// fascia cliccabile del rigo
const top = R.lineY(st, 3, st.x0), bot = R.lineY(st, 0, st.x0);
el('rect', { x: st.x0 - sp, y: top - sp * 2, width: st.x1 - st.x0 + sp * 2, height: bot - top + sp * 3.4, fill: 'transparent', 'data-hit': 'staff', 'data-staff': si }, g);
(st.clefs || []).forEach(k => {
const y = R.yForStep(st, k.x, 2 * (k.line - 1));
const isSel = S.sel && S.sel.id === k.id;
el('rect', { x: k.x - sp * 0.15, y: y - sp * 0.9, width: Math.max(sp * 0.9, (k.x1 || k.x + sp) - k.x + sp * 0.3), height: sp * 1.8, rx: sp * 0.15, fill: 'rgba(46,36,25,.08)', stroke: isSel ? 'var(--accent)' : 'rgba(46,36,25,.7)', 'stroke-width': sp * (isSel ? 0.12 : 0.06), 'data-id': k.id, 'data-staff': si }, g);
el('text', { x: k.x, y: y - sp * 1.15, 'font-size': sp * 0.8, fill: '#2E2419', 'font-family': 'var(--font-ui)', 'font-weight': 600, 'pointer-events': 'none' }, g, I18N.clef(k.type, k.line));
});
(st.eu || []).forEach(eu => {
const ns = eu.ids.map(id => st.items.find(i => i.id === id)).filter(Boolean); if (!ns.length) return;
const xa = Math.min(...ns.map(n => n.x)) - sp * 0.5, xb = Math.max(...ns.map(n => n.x)) + sp * 0.5, yy = R.lineY(st, 3, xa) - sp * 2.3;
el('path', { d: `M${xa},${yy + sp * 0.5}V${yy}H${xb}V${yy + sp * 0.5}`, fill: 'none', stroke: 'var(--accent)', 'stroke-width': sp * 0.08, 'pointer-events': 'none' }, g);
el('text', { x: xa, y: yy - sp * 0.25, 'font-size': sp * 0.75, fill: 'var(--accent)', 'font-family': 'var(--font-ui)', 'font-weight': 700, 'data-id': ns[0].id, 'data-staff': si, style: 'cursor:pointer' }, g, eu.choice === 'gloria' ? T('Euouae → Gloria Patri completo') : 'Euouae');
});
let lastClef = null;
st.items.forEach(it => {
const isSel = S.sel && S.sel.id === it.id;
if (it.kind === 'note' || it.kind === 'custos') {
const y = R.yForStep(st, it.x, it.step);
const r = sp * (it.kind === 'custos' ? 0.22 : it.liq ? 0.24 : 0.36);
el('circle', { cx: it.x, cy: y, r: r, fill: it.kind === 'custos' ? 'rgba(120,120,120,.35)' : 'rgba(181,130,10,.22)', stroke: it.kind === 'custos' ? '#777' : 'var(--overlay-note)', 'stroke-width': sp * (isSel ? 0.16 : 0.07), 'stroke-dasharray': it.shape === 'oblique' ? sp * 0.15 : null, 'data-id': it.id, 'data-staff': si }, g);
if (isSel) el('circle', { cx: it.x, cy: y, r: r + sp * 0.28, fill: 'none', stroke: 'var(--accent)', 'stroke-width': sp * 0.05, 'pointer-events': 'none' }, g);
if (it.mora) el('circle', { cx: it.x + sp * 0.6, cy: y - (it.step % 2 === 0 ? sp * 0.25 : 0), r: sp * 0.11, fill: 'var(--overlay-note)', 'pointer-events': 'none' }, g);
if (it.episema) el('line', { x1: it.x - sp * 0.35, x2: it.x + sp * 0.35, y1: y - sp * 0.62, y2: y - sp * 0.62, stroke: 'var(--overlay-note)', 'stroke-width': sp * 0.1, 'pointer-events': 'none' }, g);
if (it.ictus) el('line', { x1: it.x, x2: it.x, y1: y + sp * 0.5, y2: y + sp * 0.95, stroke: 'var(--overlay-note)', 'stroke-width': sp * 0.1, 'pointer-events': 'none' }, g);
if (it.shape === 'quilisma') el('path', { d: `M${it.x - sp * 0.4},${y - sp * 0.6}l${sp * 0.2},${-sp * 0.2}l${sp * 0.2},${sp * 0.2}l${sp * 0.2},${-sp * 0.2}l${sp * 0.2},${sp * 0.2}`, fill: 'none', stroke: 'var(--overlay-note)', 'stroke-width': sp * 0.08, 'pointer-events': 'none' }, g);
if (it.syl) {
const ty = R.lineY(st, 0, it.x) + sp * 1.55;
const t = el('text', { x: it.x, y: ty, 'text-anchor': 'middle', 'font-size': sp * 0.72, 'font-family': 'var(--font-ui)', 'font-weight': 600, fill: '#9B2D20', 'font-style': it.syl.conf != null && it.syl.conf < 60 ? 'italic' : 'normal', opacity: it.syl.conf != null && it.syl.conf < 60 ? 0.7 : 1, 'paint-order': 'stroke', stroke: '#fff', 'stroke-width': sp * 0.22, 'data-id': it.id, 'data-staff': si }, g, it.syl.text + (it.syl.hyph ? '-' : ''));
t.style.cursor = 'pointer';
}
} else if (it.kind === 'bar') {
const y0 = R.yForStep(st, it.x, 6), y1 = R.yForStep(st, it.x, 0);
el('rect', { x: it.x - sp * 0.25, y: y0 - sp * 0.4, width: sp * 0.5, height: y1 - y0 + sp * 0.8, fill: 'rgba(94,122,87,.18)', stroke: isSel ? 'var(--accent)' : 'rgba(94,122,87,.95)', 'stroke-width': sp * (isSel ? 0.12 : 0.05), 'data-id': it.id, 'data-staff': si }, g);
el('text', { x: it.x, y: y0 - sp * 0.6, 'text-anchor': 'middle', 'font-size': sp * 0.6, fill: '#5E7A57', 'font-family': 'var(--font-ui)', 'font-weight': 600, 'pointer-events': 'none' }, g, { minima: '¼', minor: '½', maior: '|', finalis: '||' }[it.type] || '|');
} else if (it.kind === 'acc') {
const y = R.yForStep(st, it.x, it.step);
el('rect', { x: it.x - sp * 0.4, y: y - sp * 0.55, width: sp * 0.8, height: sp * 1.1, rx: sp * 0.12, fill: 'rgba(154,106,0,.15)', stroke: isSel ? 'var(--accent)' : '#9a6a00', 'stroke-width': sp * (isSel ? 0.12 : 0.05), 'data-id': it.id, 'data-staff': si }, g);
el('text', { x: it.x, y: y + sp * 0.3, 'text-anchor': 'middle', 'font-size': sp * 0.9, fill: '#9a6a00', 'pointer-events': 'none' }, g, it.type === 'flat' ? '♭' : '♮');
if (it.key) el('text', { x: it.x, y: y - sp * 0.75, 'text-anchor': 'middle', 'font-size': sp * 0.55, fill: '#9a6a00', 'font-family': 'var(--font-ui)', 'font-weight': 600, 'pointer-events': 'none' }, g, T('in chiave'));
} else if (it.kind === 'label') {
// etichetta o guida letta nel testo (Ps., Ant., T.P., ij…): non è una sillaba
const ty = R.lineY(st, 0, it.x) + sp * 1.55;
const t = el('text', { x: it.x, y: ty, 'text-anchor': 'middle', 'font-size': sp * 0.72, 'font-family': 'var(--font-ui)', 'font-weight': 700, 'font-style': 'italic', fill: '#5E7A57', 'paint-order': 'stroke', stroke: isSel ? '#F3D98B' : '#fff', 'stroke-width': sp * 0.22, 'data-id': it.id, 'data-staff': si }, g, it.text);
t.style.cursor = 'pointer';
}
});
});
// fantasma per l'aggiunta
ghost = el('circle', { r: sp * 0.36, fill: 'rgba(181,130,10,.3)', stroke: 'var(--overlay-note)', 'stroke-width': sp * 0.06, 'pointer-events': 'none', visibility: 'hidden' }, svg);
updateStats();
}
let ghost = null;
function updateStats() {
let n = 0, s = 0, st = 0;
S.pages.forEach(p => (p.staves || []).forEach(x => { st++; x.items.forEach(i => { if (i.kind === 'note') { n++; if (i.syl) s++; } }); }));
$('stats').textContent = S.pages.length ? T('{0} pag. · {1} righi · {2} note · {3} sillabe', S.pages.length, st, n, s) : '';
}
// ---------- selezione e modifica ----------
function findItem(sel) {
const p = S.pages[sel.page]; if (!p) return null;
const st = p.staves[sel.staff]; if (!st) return null;
if (sel.kind === 'staff') return { st, it: null };
let it = st.items.find(i => i.id === sel.id);
if (!it) it = (st.clefs || []).find(k => k.id === sel.id);
return it ? { st, it } : null;
}
function select(staff, id) { S.sel = id ? { kind: 'item', page: S.cur, staff, id } : { kind: 'staff', page: S.cur, staff }; drawOverlay(); renderInspector(); }
function snapshot() {
S.undo.push(JSON.stringify(S.pages.map(p => p.staves)));
if (S.undo.length > 80) S.undo.shift();
S.redo = [];
}
function restore(from, to) {
if (!from.length) return;
to.push(JSON.stringify(S.pages.map(p => p.staves)));
const data = JSON.parse(from.pop());
data.forEach((st, i) => { if (S.pages[i]) S.pages[i].staves = st; });
if (S.sel && !findItem(S.sel)) S.sel = null;
afterChange();
}
$('btnUndo').onclick = () => restore(S.undo, S.redo);
$('btnRedo').onclick = () => restore(S.redo, S.undo);
function afterChange(full) {
// le sillabe possono essere state corrette: riconosci di nuovo gli Euouae (le scelte fatte restano)
const pc = S.pages[S.cur]; if (pc && pc.staves && pc.ocr) pc.staves.forEach(st => GR.EU.detectStaff(st, S.settings.euouae));
drawOverlay(); renderInspector(); scheduleRender();
$('btnUndo').disabled = !S.undo.length; $('btnRedo').disabled = !S.redo.length;
if (full) showPage(S.cur);
}
function edit(fn) { const f = S.sel && findItem(S.sel); if (!f) return; snapshot(); fn(f.it, f.st); afterChange(); }
function sortItems(st) {
// ordina per x mantenendo l'ordine relativo delle note impilate
const tol = st.space * 0.3;
st.items.forEach((it, i) => it._o = i);
st.items.sort((a, b) => (Math.abs(a.x - b.x) < tol ? a._o - b._o : a.x - b.x));
st.items.forEach(it => delete it._o);
}
function deleteSel() {
const f = S.sel && findItem(S.sel); if (!f || !f.it) return;
snapshot();
const arr = f.st.items.includes(f.it) ? f.st.items : f.st.clefs;
const i = arr.indexOf(f.it); arr.splice(i, 1);
const nxt = f.st.items[Math.min(i, f.st.items.length - 1)];
S.sel = nxt && arr === f.st.items ? { kind: 'item', page: S.cur, staff: S.sel.staff, id: nxt.id } : null;
afterChange();
}
function moveSel(dir) {
const p = S.pages[S.cur]; if (!p) return;
const flat = []; p.staves.forEach((st, si) => st.items.forEach(it => flat.push([si, it.id])));
if (!flat.length) return;
let i = S.sel ? flat.findIndex(f => f[1] === S.sel.id) : -1;
i = i < 0 ? 0 : Math.max(0, Math.min(flat.length - 1, i + dir));
select(flat[i][0], flat[i][1]);
scrollToSel();
}
function scrollToSel() {
const f = S.sel && findItem(S.sel); if (!f || !f.it) return;
const node = $('overlay').querySelector(`[data-id="${f.it.id}"]`); if (node && node.scrollIntoView) node.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
// coordinate immagine dal mouse
function imgPt(ev) {
const r = $('overlay').getBoundingClientRect(), p = S.pages[S.cur];
return { x: (ev.clientX - r.left) * p.w / r.width, y: (ev.clientY - r.top) * p.h / r.height };
}
function staffAt(pt) {
const p = S.pages[S.cur]; let best = -1, bd = 1e9;
p.staves.forEach((st, si) => {
if (pt.x < st.x0 - st.space * 2 || pt.x > st.x1 + st.space * 2) return;
const yt = R.lineY(st, 3, pt.x), yb = R.lineY(st, 0, pt.x);
const d = pt.y < yt ? yt - pt.y : pt.y > yb ? pt.y - yb : 0;
if (d < st.space * 2.5 && d < bd) { bd = d; best = si; }
});
return best;
}
let drag = null;
$('overlay').addEventListener('pointerdown', ev => {
const p = S.pages[S.cur]; if (!p) return;
const pt = imgPt(ev);
const t = ev.target;
if (S.mode === 'select') {
const id = t.getAttribute('data-id'), si = t.getAttribute('data-staff');
if (id != null) {
select(+si, id);
const f = findItem(S.sel);
if (f && f.it && (f.it.kind === 'note' || f.it.kind === 'acc' || f.it.kind === 'custos') && t.tagName !== 'text') {
drag = { startY: pt.y, step0: f.it.step, moved: false };
$('overlay').setPointerCapture(ev.pointerId);
}
} else if (t.getAttribute('data-hit') === 'staff') select(+si, null);
else { S.sel = null; drawOverlay(); renderInspector(); }
return;
}
const si = staffAt(pt); if (si < 0) { msg('Fai clic dentro o vicino a un rigo.'); return; }
const st = p.staves[si];
const step = Math.round(R.stepAt(st, pt.x, pt.y));
snapshot();
let it;
if (S.mode === 'note') {
// stesso neuma della nota precedente se molto vicina
const prev = st.items.filter(i => i.kind === 'note' && i.x <= pt.x + 1).pop();
const maxN = st.items.reduce((a, i) => Math.max(a, i.neume || 0), 0);
it = { id: R.newId() + 'u', kind: 'note', x: pt.x, y: pt.y, step, shape: 'punctum', mora: false, episema: false, syl: null, neume: prev && pt.x - prev.x < st.space * 1.3 ? prev.neume : maxN + 1 };
st.items.push(it);
} else if (S.mode === 'bar') { it = { id: R.newId() + 'u', kind: 'bar', x: pt.x, type: 'minor' }; st.items.push(it); }
else if (S.mode === 'flat') { it = { id: R.newId() + 'u', kind: 'acc', x: pt.x, type: 'flat', step }; st.items.push(it); }
else if (S.mode === 'clef') {
const line = Math.min(4, Math.max(1, Math.round(step / 2) + 1));
it = { id: R.newId() + 'u', x: pt.x, x1: pt.x + st.space, type: 'c', line };
st.clefs = (st.clefs || []).concat([it]);
}
sortItems(st);
S.sel = { kind: 'item', page: S.cur, staff: si, id: it.id };
afterChange();
});
$('overlay').addEventListener('pointermove', ev => {
const p = S.pages[S.cur]; if (!p) return;
const pt = imgPt(ev);
if (drag) {
const f = findItem(S.sel); if (!f) return;
const ns = Math.round(R.stepAt(f.st, f.it.x, pt.y));
if (ns !== f.it.step) { if (!drag.moved) { snapshot(); drag.moved = true; } f.it.step = ns; drawOverlay(); renderInspector(); }
return;
}
if (ghost && S.mode !== 'select') {
const si = staffAt(pt);
if (si < 0) { ghost.setAttribute('visibility', 'hidden'); return; }
const st = p.staves[si], step = Math.round(R.stepAt(st, pt.x, pt.y));
ghost.setAttribute('cx', pt.x); ghost.setAttribute('cy', R.yForStep(st, pt.x, step)); ghost.setAttribute('visibility', 'visible');
}
});
$('overlay').addEventListener('pointerup', () => { if (drag) { const m = drag.moved; drag = null; if (m) scheduleRender(); } });
$('overlay').addEventListener('pointerleave', () => { if (ghost) ghost.setAttribute('visibility', 'hidden'); });
function setMode(m) {
S.mode = m;
document.querySelectorAll('#tools .btn').forEach(b => b.classList.toggle('on', b.dataset.mode === m));
$('stage').className = 'stage mode-' + m;
const hints = { select: '', note: 'Clic sul rigo per aggiungere una nota all\'altezza indicata.', bar: 'Clic sul rigo per aggiungere una divisio.', flat: 'Clic sulla posizione del bemolle (di norma il Si).', clef: 'Clic sulla riga della chiave per aggiungere un cambio di chiave.' };
if (hints[m]) msg(hints[m]);
}
document.querySelectorAll('#tools .btn').forEach(b => b.onclick = () => setMode(b.dataset.mode));
// ---------- ispettore ----------
function renderInspector() {
const box = $('inspector'); box.innerHTML = '';
const p = S.pages[S.cur];
const f = S.sel && findItem(S.sel);
if (!p || !f) {
box.innerHTML = p ? `Nessuna selezioneClic su una nota, una sillaba o un rigo
note riconosciute, sillabe (in corsivo quelle incerte), divisiones, custos (non trascritto). Trascina una nota in su o in giù, oppure usa ↑ ↓ . ← → per scorrere gli elementi.
` : `Carica un file per iniziare.
`;
return;
}
const { st, it } = f;
const H = (html) => { const d = document.createElement('div'); d.innerHTML = html; while (d.firstChild) box.appendChild(d.firstChild); };
const on = (id, evn, fn) => { const e = $(id); if (e) e.addEventListener(evn, fn); };
const clefIdx = p.staves.indexOf(st);
// Euouae: scelta tra trascriverlo così com'è o inserire il Gloria Patri completo
const euPanel = (eu) => {
const k = String(st.eu.indexOf(eu));
const vs = GR.EU.variants(), cur = eu.tone + '.' + eu.ending;
const unsure = eu.dist > Math.max(1, Math.round(eu.ids.length * 0.2));
H(`Euouae${unsure ? 'tono incerto: controlla ' : 'differentia del tono salmodico'}
Tono e differentia ${vs.map(v => `${v.label} `).join('')} `);
$('euK' + k).checked = eu.choice !== 'gloria'; $('euG' + k).checked = eu.choice === 'gloria';
const setChoice = c => { snapshot(); eu.choice = c; afterChange(); };
on('euK' + k, 'change', () => setChoice('keep')); on('euG' + k, 'change', () => setChoice('gloria'));
on('euV' + k, 'change', () => { snapshot(); const [t, e] = $('euV' + k).value.split('.'); eu.tone = t; eu.ending = +e; eu.dist = 0; afterChange(); });
};
if (!it) {
const k = (st.clefs || [])[0] || { type: 'c', line: 4 };
H(`${T('Rigo {0}', clefIdx + 1)}${T('{0} note', st.items.filter(i => i.kind === 'note').length)}
Chiave iniziale
Do Fa
1 2 3 4 riga (dal basso)
Escludi questo rigo dalla trascrizione
Testo Modifica le sillabe del rigo…
`);
$('iClefT').value = k.type; $('iClefL').value = String(k.line); $('iSkip').checked = !!st.skip;
const setClef = () => { snapshot(); if (!st.clefs || !st.clefs.length) st.clefs = [{ id: R.newId(), x: st.x0, x1: st.x0 + st.space, type: 'c', line: 4 }]; st.clefs.sort((a, b) => a.x - b.x); st.clefs[0].type = $('iClefT').value; st.clefs[0].line = +$('iClefL').value; afterChange(); };
on('iClefT', 'change', setClef); on('iClefL', 'change', setClef);
on('iSkip', 'change', () => { snapshot(); st.skip = $('iSkip').checked; afterChange(); });
on('iSylAll', 'click', () => editStaffText(st));
(st.eu || []).forEach(euPanel);
return;
}
if (it.kind === 'note' || it.kind === 'custos') {
{ const eu = (st.eu || []).find(e => e.ids.includes(it.id)); if (eu) euPanel(eu); }
const clef = M.clefAt(st, it.x);
const seq = S.lastSeq; const ev = seq && seq.events.find(e => (e.refs || [e.ref]).includes(it.id));
const euSel = (st.eu || []).find(e => e.ids.includes(it.id) && e.choice === 'gloria');
const modern = euSel ? T('sostituita dal Gloria Patri') : ev ? (I18N.note(['Do', 'Re', 'Mi', 'Fa', 'Sol', 'La', 'Si'][((ev.d % 7) + 7) % 7]) + (ev.alter ? '♭' : '') + Math.floor((ev.d - (S.settings.clef === 'treble8' ? 7 : 0)) / 7)) : '—';
if (it.kind === 'custos') {
H(`Custosguida di fine rigo, non trascritta
È una nota: trascrivila Elimina
`);
on('iToNote', 'click', () => edit(x => { x.kind = 'note'; x.shape = 'punctum'; x.mora = false; x.episema = false; x.syl = null; x.neume = 9999; }));
on('iDel', 'click', deleteSel);
return;
}
H(`Nota${it.liq ? 'nota liquescens' : it.liqHead === 'cephalicus' ? 'testa di cephalicus (clivis liquescens)' : it.liqHead === 'epiphonus' ? 'testa di epiphonus (podatus liquescens)' : it.shape === 'oblique' ? 'da porrectus (obliquo)' : it.shape === 'inclinatum' ? 'punctum inclinatum' : 'punctum'}
Altezza ▼ ${stepName(it.step, clef)} → ${modern} ▲
Elimina ⌫
`);
$('iMora').checked = !!it.mora; $('iEpi').checked = !!it.episema; $('iLiq').checked = !!it.liq; $('iIct').checked = !!it.ictus; $('iQui').checked = it.shape === 'quilisma';
$('iSyl').value = it.syl ? it.syl.text : ''; $('iHyph').checked = !!(it.syl && it.syl.hyph);
const idx = st.items.indexOf(it), prev = st.items.slice(0, idx).reverse().find(i => i.kind === 'note');
$('iLink').checked = !!(prev && prev.neume === it.neume); $('iLink').disabled = !prev;
on('iUp', 'click', () => edit(x => x.step++)); on('iDown', 'click', () => edit(x => x.step--));
on('iMora', 'change', () => edit(x => x.mora = $('iMora').checked));
on('iEpi', 'change', () => edit(x => x.episema = $('iEpi').checked));
on('iIct', 'change', () => edit(x => x.ictus = $('iIct').checked));
on('iQui', 'change', () => edit(x => { if ($('iQui').checked) x.shape = 'quilisma'; else if (x.shape === 'quilisma') x.shape = 'punctum'; }));
on('iLiq', 'change', () => edit(x => { x.liq = $('iLiq').checked; if (x.liq) x.shape = 'liquescens'; else if (x.shape === 'liquescens') x.shape = 'punctum'; }));
on('iLink', 'change', () => toggleLink());
const setSyl = () => edit(x => { const t = $('iSyl').value.trim(); x.syl = t ? { text: t, hyph: $('iHyph').checked } : null; });
on('iSyl', 'change', setSyl); on('iHyph', 'change', setSyl);
on('iSyl', 'keydown', e => { if (e.key === 'Enter') { e.preventDefault(); $('iSyl').blur(); } if (e.key === 'Escape') $('iSyl').blur(); });
on('iDel', 'click', deleteSel);
} else if (it.kind === 'bar') {
H(`Divisiodivisio
Tipo Divisio minima Divisio minor Divisio maior Divisio finalis
Elimina ⌫
`);
$('iBar').value = it.type;
on('iBar', 'change', () => edit(x => x.type = $('iBar').value)); on('iDel', 'click', deleteSel);
} else if (it.kind === 'acc') {
const clef = M.clefAt(st, it.x);
H(`Alterazione
Tipo Bemolle ♭ Bequadro ♮
Posizione ▼ ${stepName(it.step, clef)} ▲
In chiave vale per tutto il rigo (armatura)
Elimina ⌫
`);
$('iAcc').value = it.type; $('iKey').checked = !!it.key;
on('iKey', 'change', () => edit(x => x.key = $('iKey').checked));
on('iAcc', 'change', () => edit(x => x.type = $('iAcc').value));
on('iUp', 'click', () => edit(x => x.step++)); on('iDown', 'click', () => edit(x => x.step--)); on('iDel', 'click', deleteSel);
} else if (it.kind === 'label') {
const what = { ps: 'inizio del versetto del salmo', ant: 'si riprende l\u2019antifona: la nota guida non si trascrive', tp: 'Tempore Paschali: alleluia del tempo pasquale', v: 'versetto', r: 'responsorio', ij: 'si canta 2 volte', iij: 'si canta 3 volte', star: 'fine dell\u2019intonazione / cambio di coro', dagger: 'flexa del salmo' }[it.label] || '';
H(`Etichetta${what}
Testo
Nella trascrizione ${it.label === 'ant' ? 'indicazione «Si riprende l\u2019antifona» alla stanghetta' : it.label === 'ij' || it.label === 'iij' ? T('scritta «{0} volte» alla stanghetta', it.label === 'iij' ? 3 : 2) : 'scritta in corsivo sopra la nota che segue'}
Elimina ⌫
`);
$('iLab').value = it.text;
on('iLab', 'change', () => edit(x => x.text = $('iLab').value.trim() || x.text));
on('iDel', 'click', deleteSel);
} else if (it.type) { // chiave
H(`Chiave
Tipo e riga Do Fa
1 2 3 4
Elimina
`);
$('iClefT').value = it.type; $('iClefL').value = String(it.line);
const setK = () => edit(x => { x.type = $('iClefT').value; x.line = +$('iClefL').value; });
on('iClefT', 'change', setK); on('iClefL', 'change', setK); on('iDel', 'click', deleteSel);
}
}
function toggleLink() {
edit((it, st) => {
const idx = st.items.indexOf(it), prev = st.items.slice(0, idx).reverse().find(i => i.kind === 'note');
if (!prev) return;
if (prev.neume === it.neume) { const maxN = st.items.reduce((a, i) => Math.max(a, i.neume || 0), 0) + 1; const old = it.neume; st.items.slice(idx).forEach(n => { if (n.kind === 'note' && n.neume === old) n.neume = maxN; }); }
else it.neume = prev.neume;
});
}
// modifica rapida di tutte le sillabe di un rigo
function editStaffText(st) {
const notes = st.items.filter(i => i.kind === 'note' && i.syl);
const box = $('inspector');
box.innerHTML = `Sillabe del rigoseparate da spazi; "-" a fine sillaba se la parola continua
Applica Annulla
Le sillabe vengono riassegnate alle note già con sillaba, nello stesso ordine. Se ne scrivi di più o di meno, l'app le riallinea alle posizioni dei neumi.
`;
$('iAll').value = notes.map(n => n.syl.text + (n.syl.hyph ? '-' : '')).join(' ');
$('iAll').focus();
$('iAllNo').onclick = () => renderInspector();
$('iAllOk').onclick = () => {
const toks = $('iAll').value.trim().split(/\s+/).filter(Boolean).map(t => ({ text: t.replace(/-+$/, ''), hyph: /-$/.test(t) }));
snapshot();
if (toks.length === notes.length) notes.forEach((n, i) => { n.syl = { text: toks[i].text, hyph: toks[i].hyph }; });
else {
const all = st.items.filter(i => i.kind === 'note');
const anchors = notes.map(n => n.x);
const syl = toks.map((t, i) => ({ text: t.text, hyph: t.hyph, ax: anchors.length ? anchors[Math.min(anchors.length - 1, Math.round(i * (anchors.length - 1) / Math.max(1, toks.length - 1)))] : all[0].x }));
M.applySyllables(st, syl);
}
afterChange();
};
}
// ---------- tastiera ----------
document.addEventListener('keydown', ev => {
const tag = (ev.target.tagName || '').toLowerCase();
const typing = tag === 'input' || tag === 'select' || tag === 'textarea';
const mod = ev.metaKey || ev.ctrlKey;
if (mod && ev.key.toLowerCase() === 'z' && !typing) { ev.preventDefault(); ev.shiftKey ? restore(S.redo, S.undo) : restore(S.undo, S.redo); return; }
if (mod && ev.key.toLowerCase() === 'o') { ev.preventDefault(); $('file').click(); return; }
if (typing || mod) return;
const f = S.sel && findItem(S.sel);
const k = ev.key;
if (k === 'Escape') { if (!$('sheet').hidden) { $('sheet').hidden = true; return; } setMode('select'); S.sel = null; drawOverlay(); renderInspector(); return; }
if (k === 'v' || k === 'V') return setMode('select');
if (k === 'n' || k === 'N') return setMode('note');
if (k === 'b' || k === 'B') return setMode('bar');
if (k === 'f' || k === 'F') return setMode('flat');
if (k === 'c' || k === 'C') return setMode('clef');
if (k === 'ArrowRight' || k === 'Tab' && !ev.shiftKey) { ev.preventDefault(); return moveSel(1); }
if (k === 'ArrowLeft' || k === 'Tab' && ev.shiftKey) { ev.preventDefault(); return moveSel(-1); }
if (!f || !f.it) return;
const it = f.it;
if ((k === 'ArrowUp' || k === 'ArrowDown') && it.step != null) { ev.preventDefault(); edit(x => x.step += k === 'ArrowUp' ? 1 : -1); return; }
if ((k === 'ArrowUp' || k === 'ArrowDown') && it.line != null) { ev.preventDefault(); edit(x => x.line = Math.max(1, Math.min(4, x.line + (k === 'ArrowUp' ? 1 : -1)))); return; }
if (k === 'Backspace' || k === 'Delete') { ev.preventDefault(); return deleteSel(); }
if (it.kind === 'note') {
if (k === 'm' || k === 'M') return edit(x => x.mora = !x.mora);
if (k === 'e' || k === 'E') return edit(x => x.episema = !x.episema);
if (k === 'l' || k === 'L') return toggleLink();
if (k === 't' || k === 'T' || k === 'Enter') { ev.preventDefault(); const i = $('iSyl'); if (i) { i.focus(); i.select(); } }
}
});
// ---------- anteprima moderna ----------
let measCtx = null;
function measure(t, size) { if (!measCtx) measCtx = document.createElement('canvas').getContext('2d'); measCtx.font = size + 'px ' + RD.FONT; return measCtx.measureText(t).width; }
// carattere del testo (EB Garamond, incorporato): per anteprima/PNG come FontFace, per il PDF dentro jsPDF
const TEXT_FONTS = [['font-reg', 'normal', '400', 'EBGaramond-Regular.ttf'], ['font-ita', 'italic', '400', 'EBGaramond-Italic.ttf'], ['font-bold', 'normal', '700', 'EBGaramond-Bold.ttf']];
const fontB64 = id => { const e = document.getElementById(id); return e ? e.textContent.trim() : ''; };
const textFontsReady = (async () => {
try {
await Promise.all(TEXT_FONTS.map(async ([id, style, weight]) => {
const b = atob(fontB64(id)), u = new Uint8Array(b.length); for (let i = 0; i < b.length; i++) u[i] = b.charCodeAt(i);
const f = new FontFace('PG Garamond', u.buffer, { style: style, weight: weight }); await f.load(); document.fonts.add(f);
}));
} catch (e) { console.warn('Carattere del testo non caricato', e); }
measCtx = null; if (typeof scheduleRender === 'function') scheduleRender();
})();
function addPdfFonts(doc) {
try {
TEXT_FONTS.forEach(([id, style, weight, file]) => { const b = fontB64(id); if (!b) return; doc.addFileToVFS(file, b); doc.addFont(file, RD.PDF_FONT, weight === '700' ? 'bold' : style); });
} catch (e) { console.warn('Carattere del testo non incorporato nel PDF', e); }
}
function buildSeq() {
return M.buildSequence(GR.EU.expandDoc({ pages: S.pages.filter(p => p.staves) }), { clef: S.settings.clef, octave: 'auto', octaveAdj: S.settings.octaveAdj, slurs: S.settings.slurs, value: S.settings.value, episema: S.settings.episema || 'long', transpose: S.settings.transpose === 'auto' ? 'auto' : (+S.settings.transpose || 0), slurPos: S.settings.slurPos || 'below' });
}
// ---------- piè di pagina: logo e nome del sito ----------
// Sul sito (plugin) arrivano da WordPress; nella versione Mac dalle impostazioni.
const SITE = window.PEREGRINUS_SITE || null;
const IMGS = {};
function footerCfg() {
if (SITE) return SITE.footer === false ? null : { name: SITE.name || '', url: SITE.url || '', logo: IMGS.logo ? { key: 'logo', aspect: IMGS.logo.aspect } : null };
if (!S.settings.foot) return null;
return { name: S.settings.footName || '', url: S.settings.footUrl || '', logo: IMGS.logo ? { key: 'logo', aspect: IMGS.logo.aspect } : null };
}
async function loadFooterLogo() {
delete IMGS.logo;
const src = SITE ? SITE.logo : S.settings.footLogo;
if (!src) { scheduleRender(); return; }
try {
const img = await new Promise((res, rej) => { const i = new Image(); i.onload = () => res(i); i.onerror = () => rej(new Error('logo')); i.src = src; });
const w = img.naturalWidth || 300, h = img.naturalHeight || 100;
// versione PNG ad alta risoluzione per il PDF (funziona anche con loghi SVG)
const k = Math.min(4, 600 / Math.max(w, h)) || 1, c = document.createElement('canvas');
c.width = Math.max(1, Math.round(w * k)); c.height = Math.max(1, Math.round(h * k));
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
IMGS.logo = { img: c, png: c.toDataURL('image/png'), aspect: w / h };
} catch (e) { console.warn('Logo del piè di pagina non caricato', e); }
scheduleRender();
}
function pieceMode() { const p = S.pages.find(p => p.mode); return p ? p.mode : ''; }
// dimensione del testo: passi di mezzo punto, da −2 a +3 pt rispetto al predefinito
// dimensione del testo: punti interi da 10 a 14, predefinito 12
const TXT_MIN = 10, TXT_MAX = 14;
const textPt = () => Math.max(TXT_MIN, Math.min(TXT_MAX, Math.round(+S.settings.textPt || RD.TEXT_PT)));
function syncTextSize() { const el = $('txtSize'); if (!el) return; const v = textPt(); el.textContent = v + ' pt'; $('txtMinus').disabled = v <= TXT_MIN; $('txtPlus').disabled = v >= TXT_MAX; if ($('setTextSize')) $('setTextSize').value = String(v); }
function setTextPt(v) { S.settings.textPt = Math.max(TXT_MIN, Math.min(TXT_MAX, Math.round(v))); saveSettings(); syncTextSize(); scheduleRender(); }
function layoutOpts() { const md = pieceMode(); return { title: S.settings.title, subtitle: md ? T('Modo {0}', md) : '', footer: footerCfg(), font: textPt() / RD.defaults.s }; }
let renderT = null;
function scheduleRender() { clearTimeout(renderT); renderT = setTimeout(renderOut, 120); }
function renderOut() {
const host = $('outPages'); host.innerHTML = '';
const ready = S.pages.filter(p => p.staves);
if (!ready.length) { host.innerHTML = 'La trascrizione apparirà qui Note su pentagramma in chiave di violino, con testo e legature sui melismi. Ogni correzione fatta a sinistra si aggiorna subito.
'; $('outInfo').textContent = ''; return; }
const seq = buildSeq(); const kChanged = !S.lastSeq || S.lastSeq.keyIn !== seq.keyIn || (seq.transposeAuto && S.lastSeq.transpose !== seq.transpose); S.lastSeq = seq; if (kChanged) fillTranspose();
if (PL.on) playStop();
const L = RD.layout(seq, layoutOpts(), measure); S.layout = L; S.outPages = [];
const avail = Math.max(200, $('outView').clientWidth - 28);
const dpr = window.devicePixelRatio || 1;
const cssW = Math.min(avail, 820), k = cssW / L.opt.pageW;
L.pages.forEach((pg, pi) => {
const c = document.createElement('canvas');
c.width = Math.round(L.opt.pageW * k * dpr); c.height = Math.round(L.opt.pageH * k * dpr);
c.style.width = cssW + 'px';
const ctx = c.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, c.width, c.height);
RD.drawCanvas(ctx, pg, k * dpr, '#111', '#fff', IMGS);
// evidenzia la nota selezionata
if (S.sel) { const h = pg.hits.find(h => h.ref === S.sel.id); if (h) { ctx.save(); ctx.scale(k * dpr, k * dpr); ctx.strokeStyle = '#B5820A'; ctx.lineWidth = 1.2; ctx.strokeRect(h.x0 - 2, h.y0 - 2, h.x1 - h.x0 + 4, h.y1 - h.y0 + 4); ctx.restore(); } }
c.onclick = ev => {
const r = c.getBoundingClientRect(), x = (ev.clientX - r.left) / k, y = (ev.clientY - r.top) / k;
const h = pg.hits.find(h => x >= h.x0 - 3 && x <= h.x1 + 3 && y >= h.y0 - 3 && y <= h.y1 + 3);
if (!h) return;
for (let i = 0; i < S.pages.length; i++) for (let si = 0; si < (S.pages[i].staves || []).length; si++) if (S.pages[i].staves[si].items.some(it => it.id === h.ref)) { if (i !== S.cur) showPage(i); select(si, h.ref); scrollToSel(); return; }
};
const wrap = document.createElement('div'); wrap.className = 'opage'; wrap.appendChild(c);
host.appendChild(wrap); S.outPages.push({ wrap, k });
});
const nn = seq.events.filter(e => e.type === 'note').length;
$('outInfo').textContent = L.pages.length === 1 ? T('1 pagina A4 · {0} note', nn) : T('{0} pagine A4 · {1} note', L.pages.length, nn);
if (S.sel) renderInspectorPitchOnly();
}
function renderInspectorPitchOnly() { const f = S.sel && findItem(S.sel); if (f && f.it && f.it.kind === 'note' && document.activeElement && document.activeElement.id !== 'iSyl') renderInspector(); }
// ---------- ascolto ----------
// Sintesi con Web Audio, senza file esterni (funziona anche offline). Si sente quello che è scritto:
// trasposizione e ottava comprese (la chiave di violino ottavata suona un'ottava sotto).
// Valori: croma = 1 battito alla velocità scelta; le stanghette aggiungono un respiro.
const BAR_REST = { minima: 0.25, minor: 1, maior: 2, finalis: 3 }; // in crome (minima: appena un respiro)
function playSchedule(seq, fromRef) {
const out = []; let t = 0, started = !fromRef;
const oct = seq.clef === 'treble8' ? -12 : 0;
seq.events.forEach(e => {
if (!started && e.type === 'note' && (e.ref === fromRef || (e.refs || []).includes(fromRef))) started = true;
if (!started) return;
if (e.type === 'bar') { if (out.length) t += BAR_REST[e.bar] != null ? BAR_REST[e.bar] : 1; return; }
if (e.type !== 'note') return;
const dur = seq.rhythmic ? (e.dur || 1) : 1;
out.push({ t, dur, midi: M.semis(e.d, e.alter || 0) + 12 + oct, ref: e.ref, refs: e.refs || [e.ref] });
t += dur;
});
return out;
}
function voiceNote(ctx, dest, f, t, len, kind) {
const g = ctx.createGain(); g.connect(dest);
const a = Math.min(0.06, len * 0.3), r = Math.min(0.09, len * 0.35), end = t + len;
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(1, t + a); g.gain.setValueAtTime(1, Math.max(t + a, end - r)); g.gain.linearRampToValueAtTime(0, end + 0.02);
const oscs = [];
if (kind === 'voice') {
const o = ctx.createOscillator(); o.type = 'sawtooth'; o.frequency.value = f;
const vib = ctx.createOscillator(), vg = ctx.createGain(); vib.frequency.value = 5.2; vg.gain.value = f * 0.004; vib.connect(vg); vg.connect(o.frequency);
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2600; o.connect(lp);
[[800, 6, 1], [1150, 8, 0.6], [2900, 10, 0.12]].forEach(([fq, q, lv]) => { const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = fq; bp.Q.value = q; const bg = ctx.createGain(); bg.gain.value = lv * 3.2; lp.connect(bp); bp.connect(bg); bg.connect(g); });
oscs.push(o, vib);
} else {
[[1, 0.6], [2, 0.22], [3, 0.08], [4, 0.04]].forEach(([h, lv]) => { const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = f * h; const og = ctx.createGain(); og.gain.value = lv; o.connect(og); og.connect(g); oscs.push(o); });
}
oscs.forEach(o => { o.start(t); o.stop(end + 0.05); });
}
function playMarkTo(ref) {
const L = S.layout; if (!L || !S.outPages) return;
for (let pi = 0; pi < L.pages.length; pi++) {
const h = L.pages[pi].hits.find(h => h.ref === ref); if (!h || !S.outPages[pi]) continue;
const { wrap, k } = S.outPages[pi];
if (!PL.mark || PL.mark.parentNode !== wrap) { if (PL.mark) PL.mark.remove(); PL.mark = document.createElement('div'); PL.mark.className = 'playmark'; wrap.appendChild(PL.mark); }
Object.assign(PL.mark.style, { left: (h.x0 - 2) * k + 'px', top: (h.y0 - 2) * k + 'px', width: (h.x1 - h.x0 + 4) * k + 'px', height: (h.y1 - h.y0 + 4) * k + 'px' });
// tieni la nota in vista
const vw = $('outView'), r = PL.mark.getBoundingClientRect(), rv = vw.getBoundingClientRect();
if (r.top < rv.top + 40 || r.bottom > rv.bottom - 40) vw.scrollTop += r.top - rv.top - rv.height * 0.35;
return;
}
}
// cursore anche sull'originale (pannello di sinistra): riquadro attorno alle note gregoriane che suonano
// (più d'una quando una nota del pentagramma riunisce note ribattute); cambia pagina e scorre da solo
function playMarkSrc(refs) {
const pi = PL.pageOf[refs[0]]; if (pi == null) return;
if (pi !== S.cur) showPage(pi);
const svg = $('overlay'); if (!svg) return;
let box = null;
refs.forEach(r => { const n = svg.querySelector(`[data-id="${r}"]`); if (!n || !n.getBBox) return; const b = n.getBBox(); box = box ? { x0: Math.min(box.x0, b.x), y0: Math.min(box.y0, b.y), x1: Math.max(box.x1, b.x + b.width), y1: Math.max(box.y1, b.y + b.height) } : { x0: b.x, y0: b.y, x1: b.x + b.width, y1: b.y + b.height }; });
if (!box) return;
if (!PL.srcMark || PL.srcMark.parentNode !== svg) { PL.srcMark = document.createElementNS(SVGNS, 'rect'); PL.srcMark.setAttribute('class', 'srcplay'); svg.appendChild(PL.srcMark); }
const p = S.pages[S.cur], pad = (p && p.space ? p.space : 20) * 0.25;
['x', 'y', 'width', 'height'].forEach((k, j) => PL.srcMark.setAttribute(k, [box.x0 - pad, box.y0 - pad, box.x1 - box.x0 + 2 * pad, box.y1 - box.y0 + 2 * pad][j]));
PL.srcMark.setAttribute('rx', pad);
const vw = $('srcView'), r = PL.srcMark.getBoundingClientRect(), rv = vw.getBoundingClientRect();
if (r.top < rv.top + 30 || r.bottom > rv.bottom - 30) vw.scrollTop += r.top - rv.top - rv.height * 0.35;
if (r.left < rv.left + 20 || r.right > rv.right - 20) vw.scrollLeft += r.left - rv.left - rv.width * 0.3;
}
function playStart() {
if (!needData()) return;
const seq = S.lastSeq || buildSeq();
const fromRef = S.sel && S.sel.id;
let notes = playSchedule(seq, fromRef);
if (!notes.length && fromRef) notes = playSchedule(seq, null);
if (!notes.length) return;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) { msg('Questo browser non può riprodurre l\u2019audio.'); return; }
const ctx = new AC(), master = ctx.createGain(), comp = ctx.createDynamicsCompressor();
master.gain.value = 0.22; master.connect(comp); comp.connect(ctx.destination);
const beat = 60 / (+S.settings.playTempo || 180), t0 = ctx.currentTime + 0.12, kind = S.settings.playSound || 'organ';
notes.forEach(n => { n.at = t0 + n.t * beat; n.len = n.dur * beat; voiceNote(ctx, master, 440 * Math.pow(2, (n.midi - 69) / 12), n.at, n.len * 0.97, kind); });
const pageOf = {}; S.pages.forEach((pg, pi) => (pg.staves || []).forEach(st => st.items.forEach(it => { pageOf[it.id] = pi; })));
Object.assign(PL, { on: true, ctx, master, notes, t0, pageOf });
$('btnPlay').textContent = '■ Ferma'; $('btnPlay').classList.add('on');
let cur = -1;
const tick = () => {
if (!PL.on) return;
const now = ctx.currentTime;
let i = cur; while (i + 1 < notes.length && notes[i + 1].at <= now) i++;
if (i !== cur && i >= 0) { cur = i; playMarkTo(notes[i].ref); playMarkSrc(notes[i].refs); }
const last = notes[notes.length - 1];
if (now > last.at + last.len + 0.2) { playStop(); return; }
PL.raf = requestAnimationFrame(tick);
};
PL.raf = requestAnimationFrame(tick);
}
function playStop() {
PL.on = false; cancelAnimationFrame(PL.raf);
if (PL.ctx) { try { PL.master.gain.setTargetAtTime(0, PL.ctx.currentTime, 0.02); } catch (e) {} const c = PL.ctx; setTimeout(() => c.close().catch(() => {}), 150); }
PL.ctx = null; if (PL.mark) { PL.mark.remove(); PL.mark = null; } if (PL.srcMark) { PL.srcMark.remove(); PL.srcMark = null; }
$('btnPlay').textContent = '▶ Ascolta'; $('btnPlay').classList.remove('on');
}
$('btnPlay').onclick = () => (PL.on ? playStop() : playStart());
$('setEpisema').onchange = () => { S.settings.episema = $('setEpisema').value; saveSettings(); scheduleRender(); };
$('setTempo').onchange = () => { S.settings.playTempo = +$('setTempo').value; saveSettings(); };
$('setSound').onchange = () => { S.settings.playSound = $('setSound').value; saveSettings(); };
// ---------- esportazione ----------
function download(blob, name) {
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
}
function needData() { if (!S.pages.some(p => p.staves && p.staves.length)) { msg('Carica prima una partitura.'); return false; } return true; }
function base() { return (S.settings.title || S.fileBase || 'gregoriano').replace(/[\\/:*?"<>|]+/g, '_').trim() || 'gregoriano'; }
$('expXml').onclick = () => {
if (!needData()) return;
const seq = buildSeq();
const xml = M.toMusicXML(seq, { title: S.settings.title || T('Canto gregoriano'), value: S.settings.value, clef: S.settings.clef, mode: pieceMode(), textPt: textPt() });
download(new Blob([xml], { type: 'application/vnd.recordare.musicxml+xml' }), base() + '.musicxml');
msg(T('MusicXML salvato: {0}', base() + '.musicxml'));
track('esportazione', { formato: 'musicxml', titolo: S.settings.title || '', incipit: incipit() });
};
$('expPdf').onclick = () => {
if (!needData()) return;
try {
const L = RD.layout(buildSeq(), layoutOpts(), measure);
const doc = new jspdf.jsPDF({ unit: 'pt', format: 'a4', compress: true });
addPdfFonts(doc);
doc.setProperties({ title: S.settings.title || T('Canto gregoriano'), creator: 'Peregrinus' });
L.pages.forEach((pg, i) => { if (i) doc.addPage('a4'); RD.drawPdf(doc, pg, IMGS); });
download(doc.output('blob'), base() + '.pdf');
msg(T('PDF salvato: {0}', base() + '.pdf'));
track('esportazione', { formato: 'pdf', titolo: S.settings.title || '', incipit: incipit() });
} catch (e) { errMsg(e); }
};
$('expPng').onclick = async () => {
if (!needData()) return;
const L = RD.layout(buildSeq(), layoutOpts(), measure);
const k = 300 / 72;
for (let i = 0; i < L.pages.length; i++) {
const c = document.createElement('canvas'); c.width = Math.round(L.opt.pageW * k); c.height = Math.round(L.opt.pageH * k);
const ctx = c.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, c.width, c.height);
RD.drawCanvas(ctx, L.pages[i], k, '#000', '#fff', IMGS);
const blob = await new Promise(r => c.toBlob(r, 'image/png'));
download(blob, base() + (L.pages.length > 1 ? '-p' + (i + 1) : '') + '.png');
if (L.pages.length > 1) await new Promise(r => setTimeout(r, 450));
}
track('esportazione', { formato: 'png', titolo: S.settings.title || '', incipit: incipit() });
msg(L.pages.length > 1 ? T('PNG salvati a 300 dpi ({0} pagine).', L.pages.length) : T('PNG salvato a 300 dpi (1 pagina).'));
};
// ---------- impostazioni ----------
function syncSettingsUI() {
const s = S.settings;
$('footerSettings').hidden = !!SITE; $('footerSite').hidden = !SITE;
$('setFoot').checked = s.foot !== false; $('setFootName').value = s.footName || ''; $('setFootUrl').value = s.footUrl || '';
const pv = $('footLogoPrev'); if (s.footLogo) { pv.src = s.footLogo; pv.style.display = 'block'; } else { pv.removeAttribute('src'); pv.style.display = 'none'; }
$('setTitle').value = s.title || ''; $('setClef').value = s.clef; $('setOct').value = String(s.octaveAdj); $('setValue').value = s.value; $('setSlurs').value = s.slurs;
$('setOcr').checked = !!s.ocr; $('setRed').checked = !!s.ignoreRed; $('setAbove').value = String(s.above); $('setDpi').value = String(s.dpi); $('setEuouae').value = s.euouae || 'keep'; syncTextSize(); $('setSlurPos').value = s.slurPos || 'below'; fillTranspose(); $('setTempo').value = String(s.playTempo || 180); $('setEpisema').value = s.episema || 'long'; $('setSound').value = s.playSound || 'organ';
}
$('btnSettings').onclick = () => { syncSettingsUI(); $('sheet').hidden = false; };
$('sheetClose').onclick = () => { $('sheet').hidden = true; };
[['setTitle', 'title', v => v], ['setClef', 'clef', v => v], ['setOct', 'octaveAdj', v => +v], ['setValue', 'value', v => v], ['setSlurs', 'slurs', v => v], ['setOcr', 'ocr', null], ['setRed', 'ignoreRed', null], ['setAbove', 'above', v => +v], ['setDpi', 'dpi', v => +v], ['setFoot', 'foot', null], ['setFootName', 'footName', v => v], ['setFootUrl', 'footUrl', v => v], ['setEuouae', 'euouae', v => v]].forEach(([id, key, conv]) => {
const e = $(id);
e.addEventListener(e.type === 'text' ? 'input' : 'change', () => { S.settings[key] = conv ? conv(e.value) : e.checked; saveSettings(); scheduleRender(); });
});
let reConfirm = false;
$('btnReanalyze').onclick = async () => {
if (!S.pages.length) return;
if (!reConfirm) { reConfirm = true; $('btnReanalyze').textContent = 'Conferma: le correzioni andranno perse'; $('btnReanalyze').classList.add('primary'); setTimeout(() => { reConfirm = false; $('btnReanalyze').textContent = 'Rianalizza tutte le pagine'; $('btnReanalyze').classList.remove('primary'); }, 5000); return; }
reConfirm = false; $('btnReanalyze').textContent = 'Rianalizza tutte le pagine'; $('btnReanalyze').classList.remove('primary');
if (!S.lastFiles) return;
$('sheet').hidden = true;
await openFiles(S.lastFiles, false);
};
// ---------- apertura, trascinamento, esempio ----------
$('btnOpen').onclick = $('btnOpen2').onclick = () => $('file').click();
$('file').onchange = () => { const fl = Array.from($('file').files); $('file').value = ''; openFiles(fl, false); };
const work = $('work');
['dragenter', 'dragover'].forEach(n => work.addEventListener(n, e => { e.preventDefault(); work.classList.add('drop-hl'); }));
['dragleave', 'drop'].forEach(n => work.addEventListener(n, e => { e.preventDefault(); if (n === 'drop' || e.target === work) work.classList.remove('drop-hl'); }));
work.addEventListener('drop', e => { const fl = Array.from(e.dataTransfer.files || []); if (fl.length) openFiles(fl, e.altKey && S.pages.length > 0); });
async function loadSample() {
// Salve Regina in tono semplice (Liber Usualis 1961, p. 279), PDF di GregoBase
const b64 = document.getElementById('sample-pdf').textContent.trim();
const bin = atob(b64), u = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
const f = new File([u], 'Salve Regina (tonus simplex).pdf', { type: 'application/pdf' });
await openFiles([f], false, true);
}
$('btnSample2').onclick = loadSample;
// avviso sui telefoni (schermo stretto, o basso con il tocco): si chiude con «Ho capito» e non torna più
(function () {
const n = $('phoneNote'); if (!n || !window.matchMedia) return;
let ok = false; try { ok = localStorage.getItem('peregrinus.phoneOk') === '1'; } catch (e) {}
if (ok || !matchMedia('(max-width: 759px), (max-height: 500px) and (pointer: coarse)').matches) return;
const txt = () => { $('phoneTxt').innerHTML = supC(T('Peregrinus© è pensato per lo schermo di un computer o di un tablet: sul telefono puoi provarlo, ma leggere e correggere la partitura sarà scomodo.')); };
txt(); I18N.onChange(txt);
n.hidden = false;
$('phoneOk').onclick = () => { n.hidden = true; try { localStorage.setItem('peregrinus.phoneOk', '1'); } catch (e) {} };
})();
$('btnFootLogo').onclick = () => $('footLogoFile').click();
$('footLogoFile').onchange = () => {
const f = $('footLogoFile').files[0]; $('footLogoFile').value = ''; if (!f) return;
if (f.size > 1.5e6) { msg('Logo troppo pesante: usa un\'immagine sotto 1,5 MB.'); return; }
const r = new FileReader();
r.onload = () => { S.settings.footLogo = r.result; saveSettings(); syncSettingsUI(); loadFooterLogo(); msg('Logo del piè di pagina impostato.'); };
r.readAsDataURL(f);
};
// trasposizione: da 6 semitoni sotto a 6 sopra; il nome dice dove va il Do dell'originale e l'armatura che ne risulta
const TR_NAMES = { 1: 'un semitono', 2: 'un tono', 3: 'una terza minore', 4: 'una terza maggiore', 5: 'una quarta', 6: 'un tritono' };
function trLabel(t, short) {
if (!t) return short ? T('nessuna') : T('Nessuna (come l\u2019originale)');
const k0 = (S.lastSeq && S.lastSeq.keyIn != null) ? S.lastSeq.keyIn : 0;
const k = k0 + M.interval(t, k0).fifths, arm = k === 0 ? T('senza alterazioni') : Math.abs(k) + (k > 0 ? ' \u266F' : ' \u266D');
const to = I18N.note(M.transposeName(t, k0));
return short ? (t > 0 ? '+' : '') + t + ' (' + to + ')' : T(t > 0 ? '+{0}: {1} sopra — {2} → {3}, armatura {4}' : '−{0}: {1} sotto — {2} → {3}, armatura {4}', Math.abs(t), T(TR_NAMES[Math.abs(t)]), I18N.note('Do'), to, arm);
}
function fillTranspose() {
const v = S.settings.transpose === 'auto' ? 'auto' : String(+S.settings.transpose || 0);
const ta = S.lastSeq && S.lastSeq.transposeAuto ? S.lastSeq.transpose : null;
[['setTranspose', false], ['trSel', true]].forEach(([id, short]) => {
const el = $(id); if (!el) return; el.innerHTML = '';
// automatica: tonalità comoda per coro misto (tra parentesi quella scelta)
const oa = document.createElement('option'); oa.value = 'auto';
oa.textContent = short ? T('auto ({0})', ta == null ? '…' : (ta > 0 ? '+' : '') + ta) : T('Automatica, per coro misto: {0}', ta == null ? '…' : trLabel(ta, true));
el.appendChild(oa);
for (let t = 6; t >= -6; t--) { const o = document.createElement('option'); o.value = String(t); o.textContent = trLabel(t, short); el.appendChild(o); }
el.value = v;
});
}
function setTranspose(v) { S.settings.transpose = v === 'auto' ? 'auto' : Math.max(-6, Math.min(6, Math.round(+v || 0))); saveSettings(); fillTranspose(); scheduleRender(); }
$('setTranspose').onchange = () => setTranspose($('setTranspose').value);
$('trSel').onchange = () => setTranspose($('trSel').value);
$('setSlurPos').onchange = () => { S.settings.slurPos = $('setSlurPos').value; saveSettings(); scheduleRender(); };
fillTranspose();
$('txtMinus').onclick = () => setTextPt(textPt() - 1);
$('txtPlus').onclick = () => setTextPt(textPt() + 1);
$('setTextSize').onchange = () => setTextPt(+$('setTextSize').value);
syncTextSize();
$('btnFootLogoDel').onclick = () => { S.settings.footLogo = ''; saveSettings(); syncSettingsUI(); loadFooterLogo(); };
// avvio
loadFooterLogo();
// sul sito: il marchio riporta alla pagina iniziale (nella lingua della pagina da cui si è arrivati)
if (SITE && SITE.home) {
const b = document.querySelector('.brand');
if (b) { const a = document.createElement('a'); a.className = b.className; a.href = SITE.home; a.target = '_top'; a.style.textDecoration = 'none'; while (b.firstChild) a.appendChild(b.firstChild); b.replaceWith(a); }
}
track('apertura', {});
setMode('select'); renderInspector(); renderOut();
$('btnUndo').disabled = true; $('btnRedo').disabled = true;
})();