Skip to content

Commit 94060e7

Browse files
committed
feat: restructure note model, snapshot serialisation, and staff rendering
Track / note model - Replace ad-hoc note objects with a unified ISbDmNoteEvent runtime representation; remove ISbDmNote. - Track now holds measures only; polyrhythm-specific logic is moved out into a dedicated MigrationTrack used during legacy import. - Simplify and tighten edit/clipboard/undo paths around the new model. Snapshot serialisation - Introduce compact V2 snapshot format with on-demand migration. - Add ArrangementSnapshotMigrator with a clear legacy → modern path. - Cleanly separate legacy and Animada-native serialisation formats. - Add snapshot-packing (stringify/parse) and numeric-functions helpers. - Move BananaDrumUrlImporter and the legacy Note type under serialisation/migration/. Staff / polyrhythm rendering - Tuplet-aware StaffNoteViewer: glyph picker, per-pulse rest glyphs, beam-count via effectiveStepsPerPulse. - Tuplet brackets render whenever the pulse is not fully covered by a continuous beam, even for pure-rest pulses; bracket span tightened to first-notehead-center → last-notehead-center. - First-bar offset for the tuplet layer via --staff-note-prefix-width. - Play beam in the first bar aligns to the actual music start using measureFirstBarMusicStartPx() / getBoundingClientRect. - New PolyrhythmEventGroupBuilder and PolyrhythmEventNoteViewer; group consecutive rests into standard rest values. Instrument / note characteristics - INoteStyleMeta.characteristics is now mandatory and is propagated from each variant into the runtime noteStyles (Instrument.ts previously read a non-existent instrumentMeta.meta.characteristics, leaving TechniqueType info undefined at runtime). - Tag every variant in bateria-instruments.ts with the correct ExcitationMode and TechniqueType (HandTechnique / StickTechnique), including damping for the surdos. - Add ExcitationMode.Vocal + VocalCharacteristics for non-instrument samples and apply it to the spoken number sounds. - Break circular imports by loading bateriaInstruments and numberSounds dynamically inside their loaders. Misc - Numerous test updates and new specs (snapshots, snapshot-packing, Track, utils, PolyrhythmFlow integration). - Minor cleanups in App, ScoreLibrary, SelectionManager, TimeCoordinator, TrackPlayer and various UI framework components. Signed-off-by: Mike Lischke <mike@lischke-online.de>
1 parent fbeb4d7 commit 94060e7

82 files changed

Lines changed: 4513 additions & 1930 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.vscode/settings.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
"npx tsc": true,
88
"npm run -s tsc": true,
99
"npm run -s lint": true,
10-
"npx -y ts-prune": true
10+
"npx -y ts-prune": true,
11+
"npm run -s test": true,
12+
"npx eslint": true,
13+
"mv": true,
14+
"npx vitest": true
1115
},
1216
}

cspell.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,32 @@
88
"abelian",
99
"Agogo",
1010
"Agogô",
11+
"Agogôs",
1112
"animada",
13+
"Apito",
1214
"Atabaque",
1315
"Banandrum",
1416
"barline",
1517
"bateria",
18+
"beamable",
1619
"Beija",
20+
"Cabasa",
1721
"Caixa",
1822
"Capoeira",
23+
"Caxixi",
1924
"Chocalho",
2025
"Cinco",
2126
"colour",
2227
"Colours",
28+
"Cuica",
29+
"Cuicas",
2330
"deserialise",
2431
"deserialisers",
2532
"Dois",
33+
"Ganzá",
2634
"guiderail",
2735
"initialise",
36+
"inspectable",
2837
"Metas",
2938
"Nove",
3039
"Oito",
@@ -34,16 +43,19 @@
3443
"prefresh",
3544
"Quatro",
3645
"recalc",
46+
"Reco",
3747
"repi",
3848
"Repinique",
3949
"repiniques",
4050
"Rimshot",
51+
"rimshots",
4152
"Scorebook",
4253
"Seis",
4354
"serialisation",
4455
"serialise",
4556
"Serialised",
4657
"Sete",
58+
"Shekere",
4759
"spacebar",
4860
"Surdo",
4961
"surdos",
@@ -54,6 +66,8 @@
5466
"Três",
5567
"tuplet",
5668
"tuplets",
69+
"unbeamed",
70+
"unpitched",
5771
"Whippies"
5872
],
5973
"ignoreWords": [

package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,16 @@
1212
"scripts": {
1313
"dev": "vite --host",
1414
"build": "npm run generate-themes && vite build",
15-
"serve:e2e": "node build/serve-dist.mjs",
15+
"watch": "npm run watch:src & npm run watch:tests & wait",
16+
"watch:src": "tsc --noEmit --watch",
17+
"watch:tests": "tsc -p tests/tsconfig.json --noEmit --watch",
1618
"test": "vitest --no-watch",
19+
"lint": "eslint 'src/**/*.{ts,tsx}' 'tests/**/*.{ts,tsx}'",
1720
"test:e2e": "playwright test",
1821
"test:e2e:headed": "playwright test --headed",
1922
"test:e2e:ui": "playwright test --ui",
23+
"serve:e2e": "node build/serve-dist.mjs",
2024
"playwright:install": "playwright install chromium",
21-
"lint": "eslint 'src/**/*.{ts,tsx}'",
22-
"watch": "tsc --noEmit --watch",
2325
"generate-themes": "tsx build/generate-daisyui-themes.ts"
2426
},
2527
"dependencies": {

src/App.scss

Lines changed: 95 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,7 @@ h6 {
414414
}
415415

416416
.note-line {
417+
position: relative;
417418
transition: opacity 0.2s;
418419
overflow-x: hidden;
419420
}
@@ -422,6 +423,14 @@ h6 {
422423
display: flex;
423424
}
424425

426+
.note-line .polyrhythms-wrapper,
427+
.bar-track-row .polyrhythms-wrapper {
428+
position: absolute;
429+
inset: 0;
430+
width: 100%;
431+
pointer-events: none;
432+
}
433+
425434
.note-line .polyrhythm-viewer {
426435
position: absolute;
427436
padding-left: calc(1.5pt + 4px);
@@ -512,8 +521,9 @@ h6 {
512521
top: 50%;
513522
left: 0;
514523
right: 0;
515-
border-top: 1px solid var(--color-base-content);
524+
border-top: 1px solid var(--color-base-300);
516525
transform: translateY(-50%);
526+
z-index: -1;
517527
}
518528

519529
.staff-note-viewer-prefix {
@@ -526,15 +536,15 @@ h6 {
526536
align-items: center;
527537
justify-content: center;
528538
gap: 10px;
529-
color: var(--color-base-content);
539+
color: var(--color-base-300);
530540
pointer-events: none;
531541
}
532542

533543
.staff-note-viewer-neutral-clef {
534544
width: 8px;
535545
height: var(--staff-barline-height);
536-
border-left: 3px solid currentColor;
537-
border-right: 3px solid currentColor;
546+
border-left: 3px solid var(--color-base-content);
547+
border-right: 3px solid var(--color-base-content);
538548
border-radius: 1px;
539549
}
540550

@@ -614,32 +624,29 @@ h6 {
614624
flex-shrink: 0;
615625
}
616626

617-
.staff-note-viewer-run.beam-right::before,
618-
.staff-note-viewer-run.beam-right::after {
619-
content: "";
627+
/* Beam stroke attached to a beamed note. Position (top, left/right, width) is set inline by
628+
StaffNoteViewer.renderBeamSegments per beam segment (shared vs partial, level 1..3). */
629+
.staff-note-viewer-beam {
620630
position: absolute;
621-
left: calc(50% + 5px);
622-
right: calc(-50% - 5px);
623631
height: 3px;
624632
background: var(--color-base-content);
625633
pointer-events: none;
626634
z-index: 2;
627635
}
628636

629-
.staff-note-viewer-run.beam-right::before {
630-
top: calc(50% - 36px);
631-
}
632-
633-
.staff-note-viewer-run.beam-right::after {
634-
top: calc(50% - 31px);
635-
}
636-
637637
.staff-note-viewer-tuplets {
638638
position: absolute;
639639
inset: 0;
640640
pointer-events: none;
641641
}
642642

643+
/* In the first bar the prefix (clef + time signature) sits to the left of the runs area; the
644+
tuplets layer must align with the runs, not with the bar's outer edge, so its bracket
645+
percentages refer to the same horizontal extent as the noteheads. */
646+
.staff-note-viewer.first-bar .staff-note-viewer-tuplets {
647+
left: var(--staff-note-prefix-width);
648+
}
649+
643650
.staff-note-viewer-tuplet-number {
644651
position: absolute;
645652
top: calc(50% - 55px);
@@ -656,12 +663,42 @@ h6 {
656663
z-index: 2;
657664
}
658665

666+
/*
667+
* Tuplet bracket: drawn when the tuplet pulse is not covered by a continuous beam (e.g. a
668+
* triplet of 8th-note + 8th-rest + 8th-rest). Spans the pulse horizontally with a thin top
669+
* line and short downward ticks at each end; the number sits centered, breaking the line.
670+
*/
671+
.staff-note-viewer-tuplet-bracket {
672+
position: absolute;
673+
top: calc(50% - 55px);
674+
height: 9px;
675+
border-top: 1.5px solid var(--color-base-content);
676+
border-left: 1.5px solid var(--color-base-content);
677+
border-right: 1.5px solid var(--color-base-content);
678+
border-bottom: 0;
679+
box-sizing: border-box;
680+
color: var(--color-base-content);
681+
z-index: 2;
682+
pointer-events: none;
683+
}
684+
685+
.staff-note-viewer-tuplet-bracket > .staff-note-viewer-tuplet-text {
686+
position: absolute;
687+
top: -10px;
688+
left: 50%;
689+
transform: translateX(-50%);
690+
font-size: 16px;
691+
font-weight: 700;
692+
line-height: 1;
693+
background: var(--color-base-100);
694+
padding: 0 3px;
695+
}
696+
659697
/* Note symbol: 16th-note SVG icon */
660698
.staff-note-viewer-note-symbol {
661699
display: block;
662-
width: auto;
663-
height: 50px;
664-
opacity: 0.85;
700+
width: 25px;
701+
height: 60px;
665702

666703
/* The note head is in the lower part of this SVG; lift it so the head center sits on the middle line. */
667704
transform: translateY(-14.5px);
@@ -670,9 +707,36 @@ h6 {
670707
/* Rest symbol: 16th-rest SVG icon */
671708
.staff-note-viewer-rest-symbol {
672709
display: block;
673-
width: auto;
710+
width: 22px;
674711
height: 40px;
675-
opacity: 0.7;
712+
}
713+
714+
/* Augmentation dots are absolutely positioned so they don't displace the centered note / rest
715+
glyph in the flex slot. Without this, flex centering would shift the glyph left by half the
716+
dot+gap width, opening a gap between the note stem and any beam (which anchors at slot center). */
717+
718+
/* Augmentation dot drawn next to a grouped rest icon (no dotted-rest SVGs exist). */
719+
.staff-note-viewer-rest-dot {
720+
position: absolute;
721+
/* Rest icons have some transparent padding on the right; offset so the dot reads as adjacent. */
722+
left: calc(50% + 8px);
723+
top: calc(50% + 6px);
724+
width: 4px;
725+
height: 4px;
726+
border-radius: 50%;
727+
background: var(--color-base-content);
728+
}
729+
730+
/* Augmentation dot drawn next to a dotted note glyph (no dotted-note SVGs exist). */
731+
.staff-note-viewer-note-dot {
732+
position: absolute;
733+
/* Just past the right edge of the 25px-wide notehead icon (centered in slot). */
734+
left: calc(50% + 8px);
735+
top: calc(50% + 4px);
736+
width: 4px;
737+
height: 4px;
738+
border-radius: 50%;
739+
background: var(--color-base-content);
676740
}
677741

678742
/* Polyrhythm fragment: an absolutely-positioned slice of a polyrhythm within a bar */
@@ -770,6 +834,15 @@ h6 {
770834
align-items: center;
771835
}
772836

837+
/* Polyrhythm notes: anchor symbols to the left edge of their flex cell so the i-th note's
838+
on-screen position matches its actual time position (i/N of the pulse), rather than the
839+
cell's center. With centered symbols the first note appeared delayed relative to playback
840+
— most visible at low BPMs — because each note sat in the middle of its 1/N subdivision. */
841+
.polyrhythm-fragment .note-details-viewer,
842+
.polyrhythm-viewer .note-details-viewer {
843+
justify-content: flex-start;
844+
}
845+
773846
.note-details-viewer .note-style-symbol {
774847
width: 70%;
775848
height: 70%;

src/App.tsx

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,11 @@ import { AppStorage, type IUISettings } from "./core/AppStorage.js";
3939
import {
4040
SbDmEntityType, ScoreBookDataModel, type ISbDmScore, type ISbDmScoreFolder
4141
} from "./core/ScoreBookDataModel.js";
42-
import { BananaDrumUrlImporter } from "./core/serialisation/BananaDrumUrlImporter.js";
43-
import { isNaturalNumber } from "./core/serialisation/snapshot-version.js";
42+
import { ArrangementSnapshotMigrator } from "./core/serialisation/migration/ArrangementSnapshotMigrator.js";
43+
import {
44+
stringifyPackedArrangement
45+
} from "./core/serialisation/snapshot-packing.js";
46+
import { isNaturalNumber } from "./core/serialisation/snapshots.js";
4447
import type { IArrangementSnapshot, ISerialisedArrangement } from "./core/types/general.js";
4548
import { UndoManager } from "./core/UndoManager.js";
4649
import { convertErrorToString } from "./core/utils.js";
@@ -72,7 +75,7 @@ interface IAppState {
7275
headerPinned: boolean;
7376
}
7477

75-
const newSong: ISerialisedArrangement = { composition: emptySongString, version: 2, title: "New Song" };
78+
const newSong: ISerialisedArrangement = { composition: emptySongString, version: 1, title: "New Song" };
7679

7780
export class App extends UIComponent<{}, IAppState> {
7881
private scoreLibraryRef = createRef<DrawerSidebar>();
@@ -120,11 +123,12 @@ export class App extends UIComponent<{}, IAppState> {
120123
this.applyThemePreference(this.selectedThemePreference);
121124
this.systemThemeQuery.addEventListener("change", this.handleSystemThemeChange);
122125
escapeStack.attach();
126+
123127
requisitions.register("settingsChanged", this.handleSettingsChanged);
124128
requisitions.register("playRangeChanged", this.handlePlayRangeChanged);
125129

126130
void this.dataModel.initialize().then(() => {
127-
const arrangementSnapshot = BananaDrumUrlImporter.getArrangementSnapshotFromParams(
131+
const arrangementSnapshot = ArrangementSnapshotMigrator.migrateFromParams(
128132
new URL(window.location.href).searchParams,
129133
this.dataModel.instruments
130134
);
@@ -483,7 +487,20 @@ export class App extends UIComponent<{}, IAppState> {
483487
try {
484488
const params = new URL(url).searchParams;
485489
const title = params.get("t") ?? "Imported Score";
486-
await this.dataModel.addScore(title, params.toString(), parent);
490+
491+
// Migrate the BananaDrum link to the current snapshot
492+
// format and store it in the compact V2 wire format.
493+
const snapshot = ArrangementSnapshotMigrator.migrateFromParams(
494+
params,
495+
this.dataModel.instruments,
496+
);
497+
if (!snapshot) {
498+
throw new Error("URL does not contain a recognised score payload");
499+
}
500+
501+
snapshot.title = title;
502+
const content = stringifyPackedArrangement(snapshot);
503+
await this.dataModel.addScore(title, content, parent);
487504

488505
return true;
489506
} catch (error) {
@@ -548,12 +565,7 @@ export class App extends UIComponent<{}, IAppState> {
548565
});
549566

550567
if (data.type === SbDmEntityType.Score) {
551-
const params = new URLSearchParams(data.content);
552-
const arrangementSnapshot = BananaDrumUrlImporter.getArrangementSnapshotFromParams(
553-
params,
554-
this.dataModel.instruments
555-
);
556-
568+
const arrangementSnapshot = this.dataModel.decodeScoreContent(data);
557569
this.loadScorebook(arrangementSnapshot ?? newSong);
558570
}
559571

@@ -637,7 +649,8 @@ export class App extends UIComponent<{}, IAppState> {
637649
const resolvedArrangementToLoad = arrangementToLoad ?? newSong;
638650
const arrangement = this.dataModel.loadArrangement(resolvedArrangementToLoad);
639651
if (snapshotToLoad) {
640-
arrangement.applyArrangementSnapshot(snapshotToLoad, this.dataModel.instruments);
652+
const currentSnapshot = ArrangementSnapshotMigrator.migrate(snapshotToLoad, this.dataModel.instruments);
653+
arrangement.applyArrangementSnapshot(currentSnapshot, this.dataModel.instruments);
641654
}
642655
this.undoManager = new UndoManager(this.dataModel);
643656
this.stopCurrentScoreAutoSave = this.undoManager.topics.currentState.subscribe(() => {

src/assets/images/notes/half-rest.svg

Lines changed: 1 addition & 1 deletion
Loading

src/assets/images/notes/whole-rest.svg

Lines changed: 1 addition & 1 deletion
Loading

0 commit comments

Comments
 (0)