Preserve and display bilingual and phonetic lyric cues

This commit is contained in:
Jonathan Sykes
2026-10-03 15:20:52 +08:00
parent c8ab741d72
commit d4354d220e
13 changed files with 78 additions and 11 deletions

12
docs/lyric-languages.md Normal file
View File

@@ -0,0 +1,12 @@
# Translations and pronunciation
A cue can contain a primary lyric and translation separated by ` / `:
```
[1:23.45] 主你的恩典 / Your grace is enough
!pinyin: zhǔ nǐ de ēndiǎn
```
The translation and phonetic text share the primary cue timestamp. They are smaller and muted beneath it in stage lyrics, Service mode, Presenter and OBS. The notes API preserves optional `secondary` and `phonetic` fields. Existing documents with only text, timing and kind remain unchanged.
Use `| ` continuation lines (or Shift+Enter in the editor) for tight breaks within a cue. A continuation can also carry `Primary / Translation`. Multiple timestamps copy translations and pronunciation to every occurrence. A slash without surrounding spaces, such as `and/or`, remains literal. Ordinary `! Band cue` lines retain their existing meaning.

View File

@@ -3298,7 +3298,7 @@ function fitLyricLines(list, { maxShare = 0.17, maxPx = 160, minPx = 18, uniform
const fits = [];
for (const el of rows) {
// Size a grouped cue by its widest visual line, preserving tight breaks.
const parts = el.textContent.split('\n');
const parts = (el.querySelector('.lyric-primary') || el).textContent.split('\n');
widthAt.text = parts.reduce((widest, part) => {
widthAt.text = part; const width = widthAt(100);
return width > widest.width ? { text: part, width } : widest;
@@ -3483,7 +3483,7 @@ const Notes = (() => {
if (l.kind === 'cue') {
return `<div class="lyr-cue" data-i="${i}"><span>🥁 ${escapeHtml(l.text)}</span><span class="lyr-cue-in"></span></div>`;
}
return `<div class="lyr-line${time ? '' : ' untimed'}" data-i="${i}"><span class="lyr-time">${time}</span><span class="lyr-text">${escapeHtml(l.text)}</span></div>`;
return `<div class="lyr-line${time ? '' : ' untimed'}" data-i="${i}"><span class="lyr-time">${time}</span><span class="lyr-text">${LyricsDisplay.html(l)}</span></div>`;
}).join('');
if (st.sync) renderSyncPointer();
else tick(now(), true);
@@ -4160,7 +4160,7 @@ const Notes = (() => {
return;
}
list.innerHTML = items.map((l) =>
`<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${escapeHtml(l.text)}</div>`).join('');
`<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${LyricsDisplay.html(l)}</div>`).join('');
paintFlags();
loadFlags();
applyFits();
@@ -4271,7 +4271,7 @@ const Notes = (() => {
return;
}
list.innerHTML = items.map((l) =>
`<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${escapeHtml(l.text)}</div>`).join('');
`<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${LyricsDisplay.html(l)}</div>`).join('');
fitLyricLines(list, { maxShare: 0.15, maxPx: 96 });
tickStage(now(), true);
clearTimeout(stg.refit);
@@ -6060,7 +6060,7 @@ const Presenter = (() => {
const pad = Math.round(list.clientHeight / 2);
list.style.paddingTop = pad + 'px';
list.style.paddingBottom = pad + 'px';
list.innerHTML = sung.map((l) => `<div class="sl-line" data-i="${l.i}">${escapeHtml(l.text)}</div>`).join('');
list.innerHTML = sung.map((l) => `<div class="sl-line" data-i="${l.i}">${LyricsDisplay.html(l)}</div>`).join('');
refit();
st.idx = -2;
tick(true);
@@ -12449,6 +12449,7 @@ async function boot() {
PartyDJ.register(() => $('navPartyBtn').click());
SetlistImport.configure({ library: async () => { let uploads = []; try { const j = await webFetch('/api/uploads?limit=200'); uploads = j.uploads || []; } catch {} return [...data.history, ...data.playlists.flatMap(p => p.videos || []), ...uploads]; }, search: async q => (await API.search(q)).results || [], modal: showModal, close: closeModal, create: (name, videos) => { const pl = { id: uid(), name, videos: videos.map(v => ({ ...slim(v), tags: v.tags, key: v.key, chordPro: v.chordPro })) }; data.playlists.push(pl); persist(); view = { type: 'playlist', id: pl.id }; render(); toast(`Imported ${videos.length} songs`); } });
Remote.boot();
LyricsDisplay.register();
LowerThird.register(() => $('remoteChip').click());
Transition.renderButton();
PiP.render();

View File

@@ -642,6 +642,7 @@
<script src="opfs.js"></script>
<script src="video-edit.js"></script>
<script src="lyrics-core.js"></script>
<script src="lyrics-display.js"></script>
<script src="flag-ui.js"></script>
<script src="export.js"></script>
<script src="stats-core.js"></script>

View File

@@ -9,7 +9,7 @@
if (!box) { box = root.document.createElement('div'); box.id = 'obsLowerThird'; box.setAttribute('aria-live', 'polite'); root.document.body.append(box); }
const pair = cuePair(lines, index), key = JSON.stringify(pair); if (key === signature) return; signature = key;
box.replaceChildren();
for (const [role, cue] of Object.entries(pair)) { const p = root.document.createElement('div'); p.className = `obs-line obs-${role}`; p.textContent = cue?.text || ''; if (cue?.secondary || cue?.phonetic) { const secondary = root.document.createElement('small'); secondary.textContent = [cue.secondary, cue.phonetic].filter(Boolean).join(' · '); p.append(secondary); } box.append(p); }
for (const [role, cue] of Object.entries(pair)) { const p = root.document.createElement('div'); p.className = `obs-line obs-${role}`; const parts = root.LyricsCore.display(cue); p.textContent = parts.text; if (parts.secondary || parts.phonetic) { const secondary = root.document.createElement('small'); secondary.textContent = [parts.secondary, parts.phonetic].filter(Boolean).join(' · '); p.append(secondary); } box.append(p); }
}
function register(open) { root.SettingsSections.register({ id: 'broadcast', title: 'Presenter & broadcast', cluster: 'Live & worship', summary: () => 'Projector · OBS lower third', render(container) { const doc = container.ownerDocument, p = doc.createElement('p'); p.textContent = 'Start a remote-controlled screen, then copy its OBS browser-source URL. Set the browser source to 1920 × 1080. The output follows the host and remains transparent. Pairing codes expire; an already paired source reconnects until you unpair it.'; const b = doc.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Open presenter & remote controls'; b.onclick = open; container.append(p, b); } }); }
const api = { active, paint, cuePair, url, register }; if (typeof module !== 'undefined') module.exports = api; else { root.LowerThird = api; if (active()) root.document.documentElement.classList.add('obs-overlay'); }

View File

@@ -56,6 +56,11 @@
return String(s).split(/[,|·]/).map((x) => x.trim()).filter(Boolean);
}
function display(line) {
const text = String(line?.text || ''), divider = text.indexOf(' / ');
return { text: divider < 0 ? text : text.slice(0, divider).trim(), secondary: line?.secondary || (divider < 0 ? '' : text.slice(divider + 3).trim()), phonetic: line?.phonetic || '' };
}
// Editor text → { lines, tags, offset }.
function fromText(text) {
const lines = [];
@@ -65,8 +70,14 @@
for (const rawLine of String(text || '').replace(/\r/g, '').split('\n')) {
let rest = rawLine.trim();
if (!rest) continue;
if (/^!pinyin:\s*/i.test(rest) && group.some(l => l.kind === 'line')) {
const value = rest.replace(/^!pinyin:\s*/i, '').trim();
for (const line of group) if (value) line.phonetic = line.phonetic ? line.phonetic + '\n' + value : value;
continue;
}
if (rest.startsWith('| ') && group.length) {
for (const line of group) line.text += '\n' + rest.slice(2).trim();
const extra = display({ text: rest.slice(2).trim() });
for (const line of group) { line.text += '\n' + extra.text; if (extra.secondary) line.secondary = line.secondary ? line.secondary + '\n' + extra.secondary : extra.secondary; }
continue;
}
group = [];
@@ -92,6 +103,7 @@
if (!rest) continue;
group = stamps.length ? stamps.map(t => ({ t: Math.round(t * 100) / 100, text: rest, kind }))
: [{ t: null, text: rest, kind }];
if (kind === 'line') for (const line of group) { const parts = display(line); line.text = parts.text; if (parts.secondary) line.secondary = parts.secondary; }
lines.push(...group);
}
// A fully timed sheet (typical .lrc with repeated-chorus stamps) is put in
@@ -108,8 +120,11 @@
for (const l of (doc && doc.lines) || []) {
const stamp = l.t === null || l.t === undefined ? '' : `[${fmtStamp(l.t)}] `;
const mark = l.kind === 'section' ? '# ' : l.kind === 'cue' ? '! ' : '';
const parts = String(l.text || '').split('\n');
out.push(stamp + mark + parts[0], ...parts.slice(1).map(part => '| ' + part));
const parts = (l.kind === 'line' ? display(l) : { text: l.text || '', secondary: '' });
const primary = parts.text.split('\n'), secondary = parts.secondary.split('\n');
const combined = primary.map((part, i) => part + (secondary[i] ? ' / ' + secondary[i] : ''));
out.push(stamp + mark + combined[0], ...combined.slice(1).map(part => '| ' + part));
if (l.phonetic) out.push(...String(l.phonetic).split('\n').map(part => '!pinyin: ' + part));
}
return out.join('\n');
}
@@ -162,7 +177,7 @@
}
const LyricsCore = {
parseStamp, fmtStamp, fmtClock, fromText, toText,
parseStamp, fmtStamp, fmtClock, fromText, toText, display,
activeIndex, activeSection, timedCount, activeMarker, toYouTubeTimestamps,
};

View File

@@ -91,3 +91,13 @@ test('multiline cues round-trip without adding timestamps or active rows', () =>
assert.strictEqual(repeated.lines.length, 2);
assert.strictEqual(repeated.lines[1].text, doc.lines[0].text);
});
test('bilingual lyrics and pinyin share one timed cue and round-trip', () => {
const doc = LC.fromText('[1:23.45] 主你的恩典 / Your grace is enough\n!pinyin: zhǔ nǐ de ēndiǎn');
assert.equal(doc.lines.length, 1); assert.equal(doc.lines[0].t,83.45); assert.equal(doc.lines[0].secondary,'Your grace is enough'); assert.equal(doc.lines[0].phonetic,'zhǔ nǐ de ēndiǎn');
assert.deepEqual(LC.fromText(LC.toText(doc)).lines, doc.lines);
});
test('translations preserve grouped breaks, multi-stamps and legacy slash text', () => {
const doc = LC.fromText('[0:01][0:10] 主 / Lord\n| 恩典 / Grace\n!pinyin: zhǔ\n!pinyin: ēndiǎn');
assert.equal(doc.lines.length,2); assert.equal(doc.lines[1].secondary,'Lord\nGrace'); assert.equal(doc.lines[1].phonetic,'zhǔ\nēndiǎn'); assert.deepEqual(LC.fromText(LC.toText(doc)).lines, doc.lines);
assert.equal(LC.fromText('and/or').lines[0].text,'and/or'); assert.equal(LC.fromText('! Band cue').lines[0].kind,'cue');
});

View File

@@ -0,0 +1,8 @@
(function(root) {
'use strict';
const core = typeof module !== 'undefined' ? require('./lyrics-core') : root.LyricsCore;
const escape = value => String(value || '').replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' })[c]);
function html(line) { const parts = core.display(line); return `<span class="lyric-primary">${escape(parts.text)}</span>${parts.secondary ? `<small class="lyric-secondary">${escape(parts.secondary)}</small>` : ''}${parts.phonetic ? `<small class="lyric-secondary lyric-phonetic">${escape(parts.phonetic)}</small>` : ''}`; }
function register() { root.SettingsSections.register({ id: 'lyrics-language', title: 'Lyric languages', cluster: 'Live & worship', summary: () => 'Translations · phonetic lines', render(container) { const p = container.ownerDocument.createElement('p'); p.textContent = 'In the lyrics editor, write “Primary lyric / Translation” in one timed cue. Add “!pinyin: pronunciation” immediately below it. Both secondary lines follow the primary timestamp and appear smaller in Service, stage lyrics, Presenter and OBS output. Shift+Enter still creates a tight line break within one cue.'; container.append(p); } }); }
const api = { html, register }; if (typeof module !== 'undefined') module.exports = api; else root.LyricsDisplay = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,2 @@
const { test } = require('node:test'), assert = require('node:assert/strict'); const { html } = require('./lyrics-display');
test('primary and muted secondary lyric markup escape all content', () => { const result = html({ text:'<主>', secondary:'Grace & mercy', phonetic:'"zhǔ"' }); assert.match(result,/lyric-primary">&lt;主&gt;/); assert.match(result,/lyric-secondary">Grace &amp; mercy/); assert.match(result,/lyric-phonetic">&quot;zhǔ&quot;/); assert.doesNotMatch(result,/<主>/); });

View File

@@ -4353,3 +4353,6 @@ html.ios-nozoom, html.ios-nozoom body { touch-action: manipulation; }
.cache-fold > .cache-list { margin: 0; padding: 4px 10px 10px; }
@media (min-width: 900px) { .modal:has(.eq-panel) { width: min(92vw, 780px); max-width: 780px; } .eq-bands { grid-template-columns: repeat(10, minmax(44px, 1fr)); } }
.lyric-primary { white-space: pre-line; }
.lyric-secondary { display: block; white-space: pre-line; font-size: .62em; font-weight: 450; line-height: 1.2; opacity: .68; margin-top: .15em; }

View File

@@ -70,6 +70,7 @@ const SHELL = [
'/opfs-worker.js',
'/video-edit.js',
'/lyrics-core.js',
'/lyrics-display.js',
'/stats-core.js',
'/sha256.js',
'/resume-core.js',

View File

@@ -87,7 +87,9 @@ export function sanitizeLyrics(input) {
const text = cleanLyricText(l.text);
if (!text) continue;
const kind = l.kind === 'section' || l.kind === 'cue' ? l.kind : 'line';
lines.push({ t: cleanTime(l.t), text, kind });
const line = { t: cleanTime(l.t), text, kind };
if (kind === 'line') { const secondary = cleanLyricText(l.secondary), phonetic = cleanLyricText(l.phonetic); if (secondary) line.secondary = secondary; if (phonetic) line.phonetic = phonetic; }
lines.push(line);
}
const tags = (Array.isArray(input.tags) ? input.tags : [])
.map((t) => cleanText(t, 40)).filter(Boolean).slice(0, MAX_TAGS);

View File

@@ -267,3 +267,7 @@ test('saving a grouped cue preserves one timestamp in current and revision docum
const flags = await (await app.request(`/api/notes/${id}/flags`)).json();
expect(flags.flags[0].text).toBe(lines[0].text);
});
test('secondary and phonetic lyric fields survive validation without changing old documents', () => {
const result = N.sanitizeLyrics({ lines:[{t:1,text:'主',kind:'line',secondary:' Your grace\n is enough ',phonetic:' zhǔ\u0007 '},{t:2,text:'Legacy',kind:'line'}] });
expect(result.lines[0].secondary).toBe('Your grace\nis enough'); expect(result.lines[0].phonetic).toBe('zhǔ'); expect(result.lines[1]).toEqual({t:2,text:'Legacy',kind:'line'});
});

View File

@@ -65,3 +65,11 @@ for (const layout of ['classic', 'glass-stage']) for (const width of [390, 1440]
expect(await page.evaluate(() => data.settings.eq.g[7])).toBe(4);
expect(await page.locator('.eq-panel').evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
});
for (const layout of ['classic', 'glass-stage']) test(`${layout} secondary lyrics in stage and Service mode`, async ({ page }) => {
await openClassic(page); await page.route('**/api/notes/0gfX0dFLaBc', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok:true, lyrics:{rev:1,data:{offset:0,lines:[{t:0,text:'主你的恩典',secondary:'Your grace is enough',phonetic:'zhǔ nǐ de ēndiǎn',kind:'line'}]}}, chapters:null }) }));
await page.evaluate(async layout => { data.settings.layout = layout; applyAppearance(); current = {meta:{id:'0gfX0dFLaBc',title:'Fixture'}}; await Notes.load(current.meta); Notes.stageLyrics.set(true); }, layout);
await expect(page.locator('#stageLyricsList .lyric-secondary').first()).toHaveText('Your grace is enough');
await page.evaluate(() => { $('serviceMode').classList.remove('hidden'); data.settings.serviceLyrics = true; Notes.serviceRefresh(); });
await expect(page.locator('#serviceLyricsList .lyric-phonetic')).toHaveText('zhǔ nǐ de ēndiǎn');
expect(await page.locator('#serviceLyricsList .lyric-secondary').first().evaluate(el => parseFloat(getComputedStyle(el).fontSize) < parseFloat(getComputedStyle(el.parentElement).fontSize))).toBe(true);
});