Skip to content

Commit 9656e67

Browse files
RuitingMaclaude
andcommitted
Score: keep q-q-h bars visually symmetric via tied quarter notes
Twinkle's "little star" bars (q q half) rendered with a large empty slot after the half-note head because beat 4 had no glyph of its own. On desktop the gap read as a layout bug, though engraving-wise it was correct. Rewrite the three affected bars as q-q-q-q with a <tie> on the last two notes so beat 4 carries a visible notehead and the bar width matches its q-q-q-q siblings. Audio preserves the original "held" intent: each tie makes the leading note's `audioEndMs` span the tied duration while the trailing note sets `playAudio: false`, so the scheduler skips a re-attack. Visually both notes still receive `.is-active` during their own beat. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8e7de39 commit 9656e67

2 files changed

Lines changed: 42 additions & 6 deletions

File tree

public/scores/twinkle/score.mei

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,18 @@
2828
<harm place="below" staff="1" tstamp="1" n="2">I</harm>
2929
</measure>
3030

31-
<!-- Bar 2 — little star, Am7, vi -->
31+
<!-- Bar 2 — little star, Am7, vi. Beats 3-4 are a held g;
32+
written as two tied quarter notes rather than a half so
33+
beat 4 shows a notehead (engraving-equivalent, keeps the
34+
measure visually symmetric with the q-q-q-q bars). -->
3235
<measure n="2">
3336
<staff n="1"><layer n="1">
3437
<note pname="a" oct="4" dur="4"/>
3538
<note pname="a" oct="4" dur="4"/>
36-
<note pname="g" oct="4" dur="2"/>
39+
<note xml:id="tw-b2-n3" pname="g" oct="4" dur="4"/>
40+
<note xml:id="tw-b2-n4" pname="g" oct="4" dur="4"/>
3741
</layer></staff>
42+
<tie startid="#tw-b2-n3" endid="#tw-b2-n4"/>
3843
<harm place="above" staff="1" tstamp="1" n="1">Am7</harm>
3944
<harm place="below" staff="1" tstamp="1" n="2">vi</harm>
4045
</measure>
@@ -51,13 +56,15 @@
5156
<harm place="below" staff="1" tstamp="1" n="2">IV</harm>
5257
</measure>
5358

54-
<!-- Bar 4 — what you are, Dm7 → G7 (ii-V), resolving to I next bar -->
59+
<!-- Bar 4 — what you are, Dm7 → G7 (ii-V), resolving to I next bar. -->
5560
<measure n="4">
5661
<staff n="1"><layer n="1">
5762
<note pname="d" oct="4" dur="4"/>
5863
<note pname="d" oct="4" dur="4"/>
59-
<note pname="c" oct="4" dur="2"/>
64+
<note xml:id="tw-b4-n3" pname="c" oct="4" dur="4"/>
65+
<note xml:id="tw-b4-n4" pname="c" oct="4" dur="4"/>
6066
</layer></staff>
67+
<tie startid="#tw-b4-n3" endid="#tw-b4-n4"/>
6168
<harm place="above" staff="1" tstamp="1" n="1">Dm7</harm>
6269
<harm place="above" staff="1" tstamp="3" n="1">G7</harm>
6370
<harm place="below" staff="1" tstamp="1" n="2">ii</harm>
@@ -81,8 +88,10 @@
8188
<staff n="1"><layer n="1">
8289
<note pname="e" oct="4" dur="4"/>
8390
<note pname="e" oct="4" dur="4"/>
84-
<note pname="d" oct="4" dur="2"/>
91+
<note xml:id="tw-b6-n3" pname="d" oct="4" dur="4"/>
92+
<note xml:id="tw-b6-n4" pname="d" oct="4" dur="4"/>
8593
</layer></staff>
94+
<tie startid="#tw-b6-n3" endid="#tw-b6-n4"/>
8695
<harm place="above" staff="1" tstamp="1" n="1">G7</harm>
8796
<harm place="below" staff="1" tstamp="1" n="2">V7</harm>
8897
</measure>

src/lib/score-player.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ interface Note {
105105
startMs: number;
106106
endMs: number;
107107
midi: number;
108+
/** If present, audio sustains to this ms instead of `endMs` — used so
109+
* a tie-leading note plays through the whole tied duration without
110+
* the tie-trailing note re-attacking. `endMs` still bounds the
111+
* visual glow so the glyph goes dark when its beat is over. */
112+
audioEndMs?: number;
113+
/** False on tie-trailing notes so the scheduler skips them. Default
114+
* (undefined) is treated as true. */
115+
playAudio?: boolean;
108116
}
109117

110118
/**
@@ -319,6 +327,23 @@ async function renderMei(meiText: string): Promise<Rendered> {
319327
'SVG:', svgHarmIds.length, '— skipping harm sync',
320328
);
321329
}
330+
331+
// Ties: `<tie startid="#X" endid="#Y"/>` means the X note sustains
332+
// into Y without a re-attack. Extend X's audio range through Y,
333+
// and mute Y in the audio scheduler. Visuals keep both notes so
334+
// each glyph glows when its own beat is sounding.
335+
const notesById = new Map<string, Note>();
336+
for (const n of notes) notesById.set(n.id, n);
337+
for (const tie of Array.from(meiDoc.querySelectorAll('tie'))) {
338+
const startId = tie.getAttribute('startid')?.replace(/^#/, '');
339+
const endId = tie.getAttribute('endid')?.replace(/^#/, '');
340+
if (!startId || !endId) continue;
341+
const startNote = notesById.get(startId);
342+
const endNote = notesById.get(endId);
343+
if (!startNote || !endNote) continue;
344+
startNote.audioEndMs = endNote.endMs;
345+
endNote.playAudio = false;
346+
}
322347
} catch (err) {
323348
console.warn('[score] MEI metadata extraction failed:', err);
324349
}
@@ -666,8 +691,10 @@ class ScorePlayer {
666691
for (const note of this.sortedNotes) {
667692
if (note.startMs < fromMs) continue;
668693
if (note.startMs >= toMs) break;
694+
if (note.playAudio === false) continue;
669695
const startT = this.iterStart + (iterBase + note.startMs) / 1000;
670-
const endT = this.iterStart + (iterBase + note.endMs) / 1000;
696+
const audioEnd = note.audioEndMs ?? note.endMs;
697+
const endT = this.iterStart + (iterBase + audioEnd) / 1000;
671698
this.synth.play(note.midi, startT, endT);
672699
}
673700
}

0 commit comments

Comments
 (0)