Skip to content

Commit 4ce19b9

Browse files
committed
fix: tuplet detection, nested polyrhythm migration, and staff notation layout
Migration (ArrangementMigrator.ts): - Reduce actual/normal by greatestCommonDivisor before tuplet check so ratios like 9:12 (gcd=3 → 3∉{2}) are correctly marked as tuplets. - Detect nested polyrhythms (pr.start.polyrhythm !== undefined) in collectMeasureSubdivisions and countMeasureVisibleSteps so they are no longer lost due to incorrect global-position calculation. Staff notation (App.scss, print.scss, StaffBarViewer.tsx, StaffNoteViewer.tsx, StaffPrefixViewer.tsx, NoteImage.tsx, note.svg, SelectionView.ts): - Align staff-line offsets between prefix and note viewer rows. - Reposition tuplet brackets, accent marks, and note-head stems for consistent vertical alignment across the staff. - Fix beam/tuplet hit-testing: individual note hits now take priority over beam-group hits within the same row. - Replace &-suffix SCSS selectors with full class names so grep can find them, and nest child selectors under the parent scope. - Add --note-flag-offset CSS variable support to SVG note flags. - Increase oval stem stroke-width and final-barline thickness for better visibility. Test (O-Break-Salgueiro.spec.ts): - Add migration test for "O-Break Salgueiro 2007" bar 4 covering all nine tracks, including the Repinique's nested 2:1 inside a 3:4. Signed-off-by: Mike Lischke <mike@lischke-online.de>
1 parent c228b16 commit 4ce19b9

10 files changed

Lines changed: 344 additions & 100 deletions

File tree

src/App.scss

Lines changed: 50 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -460,12 +460,12 @@ h6 {
460460
flex-shrink: 0;
461461
width: 48px;
462462
margin: 0 -8px 0 8px;
463-
padding-top: calc(20pt + 5px);
463+
padding-top: 32px;
464464
}
465465

466466
.staff-measure-beam {
467467
height: 20pt;
468-
margin-bottom: 5px;
468+
margin-bottom: -14px;
469469
display: flex;
470470
align-items: center;
471471
gap: 6px;
@@ -495,9 +495,10 @@ h6 {
495495
}
496496

497497
.staff-prefix-row {
498-
position: relative;
499498
min-height: var(--note-height);
500-
margin: 10px 0;
499+
margin: 0;
500+
padding-top: 24px;
501+
margin-bottom: 20px;
501502
}
502503

503504
.staff-prefix-middle-line {
@@ -513,7 +514,7 @@ h6 {
513514
.staff-prefix-clef {
514515
margin: 0 8px;
515516
width: 8px;
516-
height: calc(var(--note-height) * 0.2);
517+
height: calc(var(--note-height) * 0.25);
517518
border-left: 3px solid var(--color-base-content);
518519
border-right: 3px solid var(--color-base-content);
519520
flex-shrink: 0;
@@ -552,11 +553,7 @@ h6 {
552553
display: flex;
553554
flex-direction: row;
554555
width: 100%;
555-
margin: 8px 0;
556-
}
557-
558-
.bar-track-row.staff-mode {
559-
margin: 10px 0;
556+
margin: 0;
560557
}
561558

562559
.staff-note-viewer {
@@ -566,6 +563,7 @@ h6 {
566563
position: relative;
567564
width: 100%;
568565
min-height: var(--note-height);
566+
margin-bottom: 20px;
569567
}
570568

571569
.staff-note-viewer-line {
@@ -577,19 +575,25 @@ h6 {
577575
}
578576

579577
.staff-note-viewer-custom-stem {
578+
position: absolute;
580579
pointer-events: none;
581580
z-index: 1;
581+
left: calc(50% - 1px);
582+
top: calc(50% - 38px);
583+
width: 2px;
584+
background: var(--color-base-content);
582585
}
583586

584587
/* Per-head-type adjustments for beamed custom stems. */
585588
.staff-note-viewer-custom-stem--square {
586-
/* Square heads (Timbau, Repinique slap) sit lower; pull stem down. */
587-
top: calc(50% - 34px) !important;
589+
/* Square heads (Timbau, Repinique slap) sit lower; pull stem down and left. */
590+
top: calc(50% - 38px) !important;
591+
left: calc(50% - 2px) !important;
588592
}
589593

590594
.staff-note-viewer-custom-stem--cross {
591595
/* Cross heads (Tamborim) are small; pull stem down slightly. */
592-
top: calc(50% - 36px) !important;
596+
top: calc(50% - 38px) !important;
593597
}
594598

595599
.staff-note-viewer-prefix {
@@ -646,7 +650,7 @@ h6 {
646650

647651
.staff-note-viewer-final-barline::before {
648652
right: 4px;
649-
width: 1.5px;
653+
width: 2px;
650654
}
651655

652656
.staff-note-viewer-final-barline::after {
@@ -659,7 +663,7 @@ h6 {
659663
position: absolute;
660664
inset: 0;
661665
display: flex;
662-
align-items: center;
666+
margin-top: 64px;
663667
}
664668

665669
.staff-note-viewer-run {
@@ -683,8 +687,8 @@ h6 {
683687

684688
.staff-note-viewer-accent {
685689
position: absolute;
686-
left: 50%;
687-
top: calc(50% - 60px);
690+
left: calc(50% - 5px);
691+
top: calc(50% + 9px);
688692
transform: translateX(-50%);
689693
font-size: 18px;
690694
line-height: 1;
@@ -738,7 +742,7 @@ h6 {
738742
}
739743

740744
.staff-note-viewer-tuplet-bracket.staff-note-viewer-tuplet-above {
741-
top: calc(50% - 55px);
745+
top: calc(50% - 24px);
742746
border-top: 1.5px solid var(--color-base-content);
743747
border-bottom: 0;
744748
}
@@ -775,7 +779,7 @@ h6 {
775779
height: 60px;
776780

777781
/* The note head is in the lower part of this SVG; lift it so the head center sits on the middle line. */
778-
transform: translateY(-18px);
782+
transform: translateY(-16px);
779783
}
780784

781785
/* ---------------------------------------------------------------------------
@@ -802,6 +806,7 @@ h6 {
802806
width: 14px;
803807
height: 14px;
804808
background: currentColor;
809+
bottom: 22px;
805810
transform: translate(-100%, -5%);
806811
}
807812

@@ -820,7 +825,7 @@ h6 {
820825

821826
.staff-note-head-cross-svg {
822827
position: absolute;
823-
left: calc(50% - 12px);
828+
left: calc(50% - 12.5px);
824829
bottom: 22px;
825830
pointer-events: none;
826831
}
@@ -834,28 +839,29 @@ h6 {
834839
}
835840

836841
/* ---- CSS stem for non-oval, non-beamed notes ---- */
837-
&-stem {
842+
.staff-note-head-stem {
838843
position: absolute;
839844
left: 50%;
840845
width: 2px;
841846
background: var(--color-base-content);
842847
transform: translateX(-50%);
843-
top: -8px;
848+
top: -6px;
844849
pointer-events: none;
845850
z-index: 1;
846851
}
847852

848853
/* Per-head-type stem adjustments (non-beamed CSS stem). */
849-
&.square &-stem {
850-
top: -8px;
854+
&.square .staff-note-head-stem {
855+
top: -6px;
856+
left: calc(50% - 1px);
851857
}
852858

853-
&.cross &-stem {
854-
top: -12px;
859+
&.cross .staff-note-head-stem {
860+
top: -8px;
855861
}
856862

857863
/* ---- Thumb: empty circle inside square ---- */
858-
&-thumb-circle {
864+
.staff-note-head-thumb-circle {
859865
position: absolute;
860866
left: calc(50% - 7px);
861867
bottom: 20px;
@@ -868,7 +874,7 @@ h6 {
868874
}
869875

870876
/* ---- Tap / TapWithPalm: triangle inside square (pointing up) ---- */
871-
&-tap-triangle {
877+
.staff-note-head-tap-triangle {
872878
position: absolute;
873879
left: calc(50% - 7px);
874880
bottom: 20px;
@@ -917,7 +923,7 @@ h6 {
917923
.staff-note-head-slap-svg {
918924
position: absolute;
919925
left: calc(50% - 7px);
920-
bottom: 20px;
926+
bottom: 22px;
921927
width: 10px;
922928
height: 10px;
923929
transform: translate(-50%, -30%);
@@ -944,12 +950,11 @@ h6 {
944950
&.rim::before {
945951
content: "";
946952
position: absolute;
947-
left: calc(50% - 7px);
948-
bottom: 15px;
949-
width: 16px;
953+
left: calc(50% - 12px);
954+
bottom: 17px;
955+
width: 13px;
950956
height: 1.5px;
951957
background: var(--color-base-content);
952-
transform: translateX(-50%);
953958
pointer-events: none;
954959
}
955960

@@ -968,7 +973,7 @@ h6 {
968973
position: absolute;
969974
left: calc(50% - 17px);
970975
bottom: 12px;
971-
width: 1.5px;
976+
width: 2px;
972977
height: 16px;
973978
background: var(--color-base-content);
974979
pointer-events: none;
@@ -1130,22 +1135,22 @@ h6 {
11301135
color: var(--color-primary) !important;
11311136
}
11321137

1133-
&-cross-svg,
1134-
&-press-roll-svg,
1135-
&-slap-svg,
1136-
&-rimshot-cross-svg {
1138+
.staff-note-head-cross-svg,
1139+
.staff-note-head-press-roll-svg,
1140+
.staff-note-head-slap-svg,
1141+
.staff-note-head-rimshot-cross-svg {
11371142
stroke: var(--color-primary) !important;
11381143
}
11391144

1140-
&-stem {
1145+
.staff-note-head-stem {
11411146
background: var(--color-primary) !important;
11421147
}
11431148

1144-
&-thumb-circle {
1149+
.staff-note-head-thumb-circle {
11451150
border-color: var(--color-primary) !important;
11461151
}
11471152

1148-
&-tap-triangle {
1153+
.staff-note-head-tap-triangle {
11491154
border-bottom-color: var(--color-primary) !important;
11501155
}
11511156

@@ -1165,6 +1170,10 @@ h6 {
11651170
.staff-note-viewer-custom-stem {
11661171
background: var(--color-primary) !important;
11671172
}
1173+
1174+
.staff-note-viewer-accent {
1175+
color: var(--color-primary) !important;
1176+
}
11681177
}
11691178

11701179
.selection-rect {

src/assets/images/notes/note.svg

Lines changed: 4 additions & 4 deletions
Loading

src/components/ui/Bar/Staff/StaffBarViewer.tsx

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,11 @@ export class StaffBarViewer extends UIComponent<IBarViewerProps, IBarViewerState
127127
for (const row of rows) {
128128
const rowRect = row.getBoundingClientRect();
129129

130-
// Tuplet brackets and numbers extend above / below the row, so
131-
// expand the hit-test rect vertically to catch clicks on them.
132-
const expandedTop = rowRect.top - 30;
133-
const expandedBottom = rowRect.bottom + 20;
130+
// Tuplet brackets/numbers extend above the row via absolute
131+
// positioning. With overflow:visible (the default) the row's
132+
// bounding rect already includes them, so no expansion is needed.
133+
const expandedTop = rowRect.top;
134+
const expandedBottom = rowRect.bottom;
134135

135136
if (!rectsIntersect(rect, rowRect.left, expandedTop, rowRect.right, expandedBottom, 0)) {
136137
continue;
@@ -259,6 +260,12 @@ export class StaffBarViewer extends UIComponent<IBarViewerProps, IBarViewerState
259260
// Beam detection runs regardless of note hits — when a beam is hit,
260261
// it takes priority over individual note entries because clicking on
261262
// a beam should select the entire beam group.
263+
// However, individual note hits have the highest priority: if a note
264+
// was already hit in this row, skip beam/tuplet detection.
265+
const rowHadNoteHits = noteEntries.some((e) => {
266+
return e.trackId === trackId && e.bar === barNumber;
267+
});
268+
262269
const beamElements = row.querySelectorAll<HTMLElement>(".staff-note-viewer-beam");
263270
const hitBeamSteps = new Set<number>();
264271

@@ -451,23 +458,26 @@ export class StaffBarViewer extends UIComponent<IBarViewerProps, IBarViewerState
451458
}
452459
}
453460

454-
// Beam hits take priority: remove any individual note entries
455-
// for this track and replace with beam group entries.
456-
for (let i = noteEntries.length - 1; i >= 0; i--) {
457-
if (noteEntries[i].trackId === trackId) {
458-
noteEntries.splice(i, 1);
461+
// Beam hits take priority over track-piece entries, but
462+
// individual note hits (already detected above) take priority
463+
// over beam hits.
464+
if (!rowHadNoteHits) {
465+
for (let i = noteEntries.length - 1; i >= 0; i--) {
466+
if (noteEntries[i].trackId === trackId) {
467+
noteEntries.splice(i, 1);
468+
}
459469
}
460-
}
461470

462-
for (const { start, end } of deduplicated) {
463-
noteEntries.push({
464-
granularity: SelectionGranularity.NoteGroup,
465-
bar: barNumber,
466-
trackId: trackId,
467-
startStep: start,
468-
endStep: end,
469-
});
470-
rowHasSoundingNotes = true;
471+
for (const { start, end } of deduplicated) {
472+
noteEntries.push({
473+
granularity: SelectionGranularity.NoteGroup,
474+
bar: barNumber,
475+
trackId: trackId,
476+
startStep: start,
477+
endStep: end,
478+
});
479+
rowHasSoundingNotes = true;
480+
}
471481
}
472482
} else {
473483
// No beams were hit — try tuplet bracket/number → NoteGroup.

src/components/ui/Bar/Staff/StaffPrefixViewer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export class StaffPrefixViewer extends UIComponent<IStaffPrefixViewerProps> {
4444
// Render staff lines matching those in StaffNoteViewer.
4545
const staffLines: ComponentChild[] = [];
4646
for (let i = 1; i <= maxNoteLine; i++) {
47-
const offset = (i - centerLine) * 10;
47+
const offset = ((i - centerLine) * 10) + 12; // 10px = line spacing, +12px = prefix-row shift
4848
staffLines.push(
4949
<div
5050
key={`prefix-line-${i}`}

0 commit comments

Comments
 (0)