@greenblacksky/honmoon 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -2
  3. package/package.json +58 -4
  4. package/src/lib/audio/context.ts +29 -0
  5. package/src/lib/audio/metronome.svelte.ts +146 -0
  6. package/src/lib/audio/mic.ts +254 -0
  7. package/src/lib/audio/pitch.ts +133 -0
  8. package/src/lib/audio/rhythmplay.ts +152 -0
  9. package/src/lib/audio/synth.ts +409 -0
  10. package/src/lib/audio/voiceplay.ts +60 -0
  11. package/src/lib/drills/defs.ts +459 -0
  12. package/src/lib/drills/questions.ts +920 -0
  13. package/src/lib/drills/registry.svelte.ts +167 -0
  14. package/src/lib/drills/runtime.svelte.ts +629 -0
  15. package/src/lib/drills/verdict.ts +241 -0
  16. package/src/lib/index.ts +117 -0
  17. package/src/lib/learn/describe.ts +72 -0
  18. package/src/lib/learn/pools.ts +44 -0
  19. package/src/lib/learn/progress.ts +100 -0
  20. package/src/lib/learn/session.ts +304 -0
  21. package/src/lib/music/bass.ts +139 -0
  22. package/src/lib/music/clefs.ts +218 -0
  23. package/src/lib/music/intervals.ts +94 -0
  24. package/src/lib/music/notes.ts +133 -0
  25. package/src/lib/music/rhythm.ts +624 -0
  26. package/src/lib/music/scales.ts +560 -0
  27. package/src/lib/music/voice.ts +227 -0
  28. package/src/lib/music/warmups.ts +318 -0
  29. package/src/lib/nav.svelte.ts +53 -0
  30. package/src/lib/notation/fretboard.ts +187 -0
  31. package/src/lib/notation/glyphdata.ts +101 -0
  32. package/src/lib/notation/glyphs.ts +71 -0
  33. package/src/lib/notation/icons.ts +12 -0
  34. package/src/lib/notation/keyboard.ts +166 -0
  35. package/src/lib/notation/rhythmsvg.ts +341 -0
  36. package/src/lib/notation/staff.ts +288 -0
  37. package/src/lib/notation/svg.ts +37 -0
  38. package/src/lib/notation/tab.ts +80 -0
  39. package/src/lib/store/honmoon.svelte.ts +329 -0
  40. package/src/lib/store/normalize.ts +23 -0
  41. package/src/lib/store/sync.ts +43 -0
  42. package/src/lib/store/types.ts +101 -0
  43. package/src/lib/styles/honmoon.css +2064 -0
  44. package/src/lib/ui/AccRow.svelte +23 -0
  45. package/src/lib/ui/BackBar.svelte +23 -0
  46. package/src/lib/ui/BassRow.svelte +30 -0
  47. package/src/lib/ui/Chips.svelte +23 -0
  48. package/src/lib/ui/DataCard.svelte +66 -0
  49. package/src/lib/ui/Fretboard.svelte +71 -0
  50. package/src/lib/ui/Keyboard.svelte +54 -0
  51. package/src/lib/ui/MetronomePanel.svelte +101 -0
  52. package/src/lib/ui/RefBar.svelte +28 -0
  53. package/src/lib/ui/RhythmBar.svelte +9 -0
  54. package/src/lib/ui/Shell.svelte +96 -0
  55. package/src/lib/ui/StaffRun.svelte +46 -0
  56. package/src/lib/ui/Stat.svelte +13 -0
  57. package/src/lib/ui/actions.ts +36 -0
  58. package/src/lib/ui/keyfollow.svelte.ts +48 -0
  59. package/src/lib/ui/overlay.ts +15 -0
  60. package/src/lib/ui/refdata.ts +355 -0
  61. package/src/lib/ui/scaleboard.ts +420 -0
  62. package/src/lib/ui/screens/DegreesRef.svelte +405 -0
  63. package/src/lib/ui/screens/DrillScreen.svelte +1155 -0
  64. package/src/lib/ui/screens/Explorer.svelte +245 -0
  65. package/src/lib/ui/screens/FamilyScreen.svelte +280 -0
  66. package/src/lib/ui/screens/Glossary.svelte +65 -0
  67. package/src/lib/ui/screens/IntervalRef.svelte +37 -0
  68. package/src/lib/ui/screens/LearnIndex.svelte +44 -0
  69. package/src/lib/ui/screens/MetronomeScreen.svelte +17 -0
  70. package/src/lib/ui/screens/PracticeHome.svelte +159 -0
  71. package/src/lib/ui/screens/RhythmRef.svelte +239 -0
  72. package/src/lib/ui/screens/ScalesScreen.svelte +210 -0
  73. package/src/lib/ui/screens/SessionDone.svelte +119 -0
  74. package/src/lib/ui/screens/SetupScreen.svelte +548 -0
  75. package/src/lib/ui/screens/StaffMap.svelte +230 -0
  76. package/src/lib/ui/screens/StatsScreen.svelte +478 -0
  77. package/src/lib/ui/screens/TapCalibration.svelte +186 -0
  78. package/src/lib/ui/tapfix.ts +93 -0
  79. package/src/lib/ui/types.ts +40 -0
  80. package/src/lib/util/list.ts +4 -0
  81. package/src/lib/util/random.ts +23 -0
  82. package/src/lib/util/text.ts +50 -0
@@ -0,0 +1,341 @@
1
+ import { HEADW, STEMW, STEMX, gMid, gPath, gW, headName, type GlyphName } from './glyphs';
2
+ import {
3
+ beamGroups,
4
+ beamsFor,
5
+ countAt,
6
+ restName,
7
+ rvalDef,
8
+ type Bar,
9
+ type TapGrade,
10
+ type TimeSig,
11
+ } from '../music/rhythm';
12
+ import { esc } from '../util/text';
13
+
14
+ /* ---- the single-line rhythm staff ----
15
+ Same engraved glyphs as the pitched staff, with one line instead of five:
16
+ noteheads sit on it, stems go up, and beams are drawn across the beat. */
17
+ /** Where the first note goes, width of one sixteenth, stem top and bottom. */
18
+ const RHL = { x0: 44, uw: 15, up: -35, dn: 35 };
19
+ /** First note's x when there is no time signature. */
20
+ const X0_WITHOUT_TIME_SIG = 18;
21
+ /** Two noteheads never closer than this, or a dotted note and the next. */
22
+ const MIN_NOTE_GAP = 16;
23
+ const MIN_GAP_AFTER_DOT = 25;
24
+ const BEAM_THICKNESS = 5;
25
+ const SECOND_BEAM_OFFSET = 7.5;
26
+ /** A sixteenth with no sixteenth beside it gets a stub this long. */
27
+ const STUB_BEAM = 8;
28
+ const DEFAULT_SCALE = 1.15;
29
+ const STAFF_INK = 'var(--staff)';
30
+ const MONO = 'IBM Plex Mono,monospace';
31
+
32
+ /** Dotted values: dotted eighth (3), dotted quarter (6), dotted half (12). */
33
+ function isDotted(sixteenths: number): boolean {
34
+ return sixteenths === 3 || sixteenths === 6 || sixteenths === 12;
35
+ }
36
+ function restGlyph(sixteenths: number): GlyphName {
37
+ if (sixteenths >= 16) {
38
+ return 'restWhole';
39
+ }
40
+ if (sixteenths >= 8) {
41
+ return 'restHalf';
42
+ }
43
+ if (sixteenths >= 4) {
44
+ return 'restQuarter';
45
+ }
46
+ return sixteenths >= 2 ? 'rest8th' : 'rest16th';
47
+ }
48
+ function rhRest(x: number, sixteenths: number): string {
49
+ const glyph = restGlyph(sixteenths);
50
+ let out = gMid(glyph, x, 0);
51
+ if (isDotted(sixteenths)) {
52
+ out += gMid('augmentationDot', x + gW(glyph) / 2 + 5, -6);
53
+ }
54
+ return out;
55
+ }
56
+ function rhTimeSig(bar: Bar, x: number): string {
57
+ return (
58
+ gMid(('timeSig' + bar.ts[0]) as GlyphName, x, -11) +
59
+ gMid(('timeSig' + bar.ts[1]) as GlyphName, x, 11)
60
+ );
61
+ }
62
+ /** The left edge of the stem of a note at x. */
63
+ const stemLeft = (x: number) => x + STEMX - STEMW / 2;
64
+ function upStem(x: number): string {
65
+ return `<rect x="${stemLeft(x).toFixed(2)}" y="${RHL.up}" width="${STEMW}" height="${-RHL.up}" fill="${STAFF_INK}"/>`;
66
+ }
67
+ /** A flag on an unbeamed eighth or sixteenth. */
68
+ function flag(x: number, sixteenths: number): string {
69
+ return gPath(beamsFor(sixteenths) > 1 ? 'flag16thUp' : 'flag8thUp', stemLeft(x), RHL.up);
70
+ }
71
+ /** A note standing alone or inside a beam group: head, stem, flag if
72
+ unbeamed and shorter than a quarter, dot. */
73
+ function noteGlyphs(x: number, sixteenths: number, beamed: boolean): string {
74
+ let out = gMid(headName(sixteenths), x, 0);
75
+ if (sixteenths < 16) {
76
+ out += upStem(x);
77
+ if (!beamed && sixteenths < 4) {
78
+ out += flag(x, sixteenths);
79
+ }
80
+ }
81
+ if (isDotted(sixteenths)) {
82
+ out += gMid('augmentationDot', x + HEADW + 5, -6);
83
+ }
84
+ return out;
85
+ }
86
+
87
+ export interface RhythmOpts {
88
+ /** width of one sixteenth */
89
+ uw?: number;
90
+ /** leave out the time signature */
91
+ noTs?: boolean;
92
+ /** index of an event to pick out */
93
+ hi?: number;
94
+ /** index of an event to leave blank, with a question mark */
95
+ blank?: number;
96
+ /** write the counting under the notes */
97
+ count?: boolean;
98
+ scale?: number;
99
+ }
100
+ /** Time is laid out proportionally, but two noteheads can never be closer
101
+ than they are wide, or a run of sixteenths turns into a smudge. Each note
102
+ takes its proportional place or the minimum gap, whichever is further on. */
103
+ function noteXs(bar: Bar, firstX: number, unitWidth: number): number[] {
104
+ const xs: number[] = [];
105
+ let at = 0;
106
+ bar.ev.forEach((event, i) => {
107
+ let x = firstX + at * unitWidth + unitWidth * 0.5;
108
+ if (i > 0) {
109
+ const gap = isDotted(bar.ev[i - 1].u) ? MIN_GAP_AFTER_DOT : MIN_NOTE_GAP;
110
+ x = Math.max(x, xs[i - 1] + gap);
111
+ }
112
+ xs.push(x);
113
+ at += event.u;
114
+ });
115
+ return xs;
116
+ }
117
+ function blankSlot(x: number): string {
118
+ return (
119
+ `<rect x="${x - 10}" y="-17" width="21" height="32" rx="4" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="4 3"/>` +
120
+ `<text x="${x + 0.5}" y="6" text-anchor="middle" font-size="17" font-weight="700" fill="var(--accent)" font-family="${MONO}">?</text>`
121
+ );
122
+ }
123
+ function tieCurve(fromX: number, toX: number): string {
124
+ return `<path d="M${fromX + 2} 7.5 Q${(fromX + toX) / 2} 18 ${toX - 2} 7.5" fill="none" stroke="${STAFF_INK}" stroke-width="1.7"/>`;
125
+ }
126
+ function beamRect(left: number, top: number, right: number): string {
127
+ return `<rect x="${left.toFixed(2)}" y="${top}" width="${(right - left).toFixed(2)}" height="${BEAM_THICKNESS}" fill="${STAFF_INK}"/>`;
128
+ }
129
+ /** Beams over a group, drawn last so they sit over the stems: one beam across
130
+ the group, and a second only where both neighbours need it — a lone
131
+ sixteenth gets a stub. */
132
+ function groupBeams(bar: Bar, xs: number[], group: { idx: number[] }): string {
133
+ const indices = group.idx;
134
+ const needsTwo = (k: number | undefined) => k != null && beamsFor(bar.ev[k].u) >= 2;
135
+ let out = beamRect(
136
+ stemLeft(xs[indices[0]]),
137
+ RHL.up,
138
+ xs[indices[indices.length - 1]] + STEMX + STEMW / 2,
139
+ );
140
+ indices.forEach((k, j) => {
141
+ if (!needsTwo(k)) {
142
+ return;
143
+ }
144
+ const prev = indices[j - 1];
145
+ const next = indices[j + 1];
146
+ const stemX = xs[k] + STEMX;
147
+ let left: number;
148
+ let right: number;
149
+ if (needsTwo(next)) {
150
+ left = stemX - STEMW / 2;
151
+ right = xs[next] + STEMX + STEMW / 2;
152
+ } else if (needsTwo(prev)) {
153
+ left = xs[prev] + STEMX - STEMW / 2;
154
+ right = stemX + STEMW / 2;
155
+ } else if (prev != null) {
156
+ left = stemX - STUB_BEAM;
157
+ right = stemX + STEMW / 2;
158
+ } else {
159
+ left = stemX - STEMW / 2;
160
+ right = stemX + STUB_BEAM;
161
+ }
162
+ out += beamRect(left, RHL.up + SECOND_BEAM_OFFSET, right);
163
+ });
164
+ return out;
165
+ }
166
+ /** The counting syllable under each attacked note. */
167
+ function countingRow(bar: Bar, xs: number[]): string {
168
+ let out = '';
169
+ let at = 0;
170
+ bar.ev.forEach((event, i) => {
171
+ const tiedOnto = i > 0 && bar.ev[i - 1].tie;
172
+ if (!event.r && !tiedOnto) {
173
+ out += `<text x="${xs[i]}" y="32" text-anchor="middle" font-size="14" font-weight="600" font-family="${MONO}" fill="var(--ink-3)">${esc(countAt(bar, at))}</text>`;
174
+ }
175
+ at += event.u;
176
+ });
177
+ return out;
178
+ }
179
+
180
+ export function rhythmSVG(bar: Bar, options: RhythmOpts = {}): string {
181
+ const unitWidth = options.uw || RHL.uw;
182
+ const firstX = options.noTs ? X0_WITHOUT_TIME_SIG : RHL.x0;
183
+ const height = 100;
184
+ const xs = noteXs(bar, firstX, unitWidth);
185
+ const lastEvent = bar.ev[bar.ev.length - 1] || { u: 4 };
186
+ const endX = Math.max(
187
+ firstX + bar.u * unitWidth,
188
+ xs[xs.length - 1] + Math.max(unitWidth * lastEvent.u * 0.5, 14),
189
+ );
190
+ const width = endX + 18;
191
+ const parts: string[] = [];
192
+ parts.push(
193
+ `<line x1="0" y1="0" x2="${(width - 8).toFixed(1)}" y2="0" stroke="var(--staff-line)" stroke-width="1.4"/>`,
194
+ );
195
+ if (!options.noTs) {
196
+ parts.push(rhTimeSig(bar, 20));
197
+ }
198
+ parts.push(
199
+ `<rect x="${(width - 10).toFixed(1)}" y="-11" width="1.8" height="22" fill="${STAFF_INK}"/>`,
200
+ );
201
+ const groups = beamGroups(bar);
202
+ const beamed = new Set(groups.flatMap((group) => group.idx));
203
+
204
+ /* highlight, drawn first so it sits behind */
205
+ const picked = options.hi;
206
+ if (picked != null && picked >= 0 && picked < xs.length) {
207
+ parts.push(
208
+ `<rect x="${xs[picked] - 11}" y="-42" width="22" height="82" rx="6" fill="var(--accent)" opacity="0.16"/>`,
209
+ );
210
+ }
211
+
212
+ bar.ev.forEach((event, i) => {
213
+ const x = xs[i];
214
+ if (options.blank === i) {
215
+ parts.push(blankSlot(x));
216
+ return;
217
+ }
218
+ if (event.r) {
219
+ parts.push(rhRest(x, event.u));
220
+ return;
221
+ }
222
+ parts.push(noteGlyphs(x, event.u, beamed.has(i)));
223
+ if (event.tie && i + 1 < bar.ev.length) {
224
+ parts.push(tieCurve(x, xs[i + 1]));
225
+ }
226
+ });
227
+ groups.forEach((group) => parts.push(groupBeams(bar, xs, group)));
228
+ if (options.count) {
229
+ parts.push(countingRow(bar, xs));
230
+ }
231
+ const scale = options.scale || DEFAULT_SCALE;
232
+ return (
233
+ `<svg viewBox="-2 -48 ${width.toFixed(1)} ${height}" width="${Math.round(width * scale)}" height="${Math.round(height * scale)}" role="img" aria-label="rhythm in ${bar.ts.join(' ')}">` +
234
+ parts.join('') +
235
+ '</svg>'
236
+ );
237
+ }
238
+
239
+ /** A single value on its own, for the note-values drill. */
240
+ export function rhOneSVG(sixteenths: number, rest: boolean, width?: number): string {
241
+ const centreX = 27;
242
+ const glyphs = rest ? rhRest(centreX, sixteenths) : noteGlyphs(centreX, sixteenths, false);
243
+ width = width || 88;
244
+ const label = rest ? restName(sixteenths) : rvalDef(sixteenths).n;
245
+ return (
246
+ `<svg viewBox="0 -46 56 80" width="${width}" height="${Math.round((width * 80) / 56)}" role="img" aria-label="${label}">` +
247
+ '<line x1="2" y1="0" x2="52" y2="0" stroke="var(--staff-line)" stroke-width="1.2"/>' +
248
+ glyphs +
249
+ '</svg>'
250
+ );
251
+ }
252
+
253
+ /** The numerals on their own, for the reference. */
254
+ export function tsGlyphSVG(ts: TimeSig, height?: number): string {
255
+ height = height || 54;
256
+ return (
257
+ `<svg viewBox="-2 -22 26 44" width="${Math.round((height * 26) / 44)}" height="${height}" role="img" aria-label="${ts[0]} ${ts[1]} time">` +
258
+ gMid(('timeSig' + ts[0]) as GlyphName, 11, -11) +
259
+ gMid(('timeSig' + ts[1]) as GlyphName, 11, 11) +
260
+ '</svg>'
261
+ );
262
+ }
263
+
264
+ /** A picture of what you played against what was written. Written onsets stand
265
+ above the line, your taps below it, and every tap is drawn — including the
266
+ ones that landed nowhere near anything, which are the ones you most need to
267
+ see. The timeline runs a little past both ends of the bar so an early or
268
+ late tap has somewhere to be. */
269
+ /** -1 if a time falls before the drawing, 1 if after it, 0 if inside. */
270
+ function outsideSide(time: number, earliest: number, latest: number): -1 | 0 | 1 {
271
+ if (time < earliest) {
272
+ return -1;
273
+ }
274
+ return time > latest ? 1 : 0;
275
+ }
276
+ export function tapTimeline(
277
+ grade: TapGrade,
278
+ bar: Bar,
279
+ start: number,
280
+ secondsPerSixteenth: number,
281
+ ): string {
282
+ const duration = bar.u * secondsPerSixteenth;
283
+ const width = 300;
284
+ const height = 62;
285
+ /** how much of a bar is drawn each side */
286
+ const PAD = 0.18;
287
+ const xAt = (time: number) => (width * ((time - start) / duration + PAD)) / (1 + 2 * PAD);
288
+ const earliest = start - duration * PAD;
289
+ const latest = start + duration * (1 + PAD);
290
+ const vertical = (x: number, y1: number, y2: number, style: string) =>
291
+ `<line x1="${x.toFixed(1)}" y1="${y1}" x2="${x.toFixed(1)}" y2="${y2}" ${style}/>`;
292
+ const parts: string[] = [];
293
+ parts.push(
294
+ `<rect x="${xAt(start).toFixed(1)}" y="24" width="${(xAt(start + duration) - xAt(start)).toFixed(1)}" height="2" fill="var(--line)"/>`,
295
+ );
296
+ /* a tick on each beat. In compound time this ticks every three sixteenths,
297
+ which matches neither the eighth (2) nor the dotted-quarter beat (6) —
298
+ kept as the original drew it; noted in docs/ROADMAP.md as a likely bug */
299
+ const beatSixteenths = bar.cmp ? 3 : 4;
300
+ for (let beat = 0; beat <= bar.ts[0]; beat++) {
301
+ const beatTime = start + beat * beatSixteenths * secondsPerSixteenth;
302
+ if (beatTime <= start + duration + 1e-6) {
303
+ parts.push(vertical(xAt(beatTime), 16, 34, 'stroke="var(--line)" stroke-width="1.4"'));
304
+ }
305
+ }
306
+ grade.want.forEach((time, k) => {
307
+ const x = xAt(time);
308
+ const hit = grade.hits[k];
309
+ parts.push(vertical(x, 8, 25, 'stroke="var(--ink-3)" stroke-width="2"'));
310
+ if (hit.err != null) {
311
+ const colour = Math.abs(hit.err) < grade.tol * 0.5 ? 'var(--good)' : 'var(--mid)';
312
+ parts.push(vertical(xAt(time + hit.err), 25, 44, `stroke="${colour}" stroke-width="2.6"`));
313
+ } else {
314
+ parts.push(
315
+ `<circle cx="${x.toFixed(1)}" cy="36" r="3.2" fill="none" stroke="var(--bad)" stroke-width="1.6"/>`,
316
+ );
317
+ }
318
+ });
319
+ (grade.stray || []).forEach((time) => {
320
+ /* off the end of the drawing: park it at the edge with a chevron, so a tap
321
+ half a bar late still shows up rather than silently disappearing */
322
+ const side = outsideSide(time, earliest, latest);
323
+ const x = side === 0 ? xAt(time) : xAt(side < 0 ? earliest : latest);
324
+ parts.push(
325
+ vertical(x, 25, 44, 'stroke="var(--bad)" stroke-width="2.6" stroke-dasharray="3 2.4"'),
326
+ );
327
+ if (side) {
328
+ parts.push(
329
+ `<path d="M${(x + side * 2).toFixed(1)} 48 l${side * 5} 3.5 l${-side * 5} 3.5z" fill="var(--bad)"/>`,
330
+ );
331
+ }
332
+ });
333
+ /* width:100% with a fixed height attribute letterboxes the drawing into a
334
+ strip in the middle; letting the height follow the aspect ratio gives the
335
+ timeline the full width of the card, which is the whole point of it. */
336
+ return (
337
+ `<svg viewBox="-4 0 ${width + 8} ${height}" style="display:block;width:100%;height:auto" role="img" aria-label="your taps against the written rhythm">` +
338
+ parts.join('') +
339
+ '</svg>'
340
+ );
341
+ }
@@ -0,0 +1,288 @@
1
+ import type { Pref } from '../store/types';
2
+ import { honmoon, mastery } from '../store/honmoon.svelte';
3
+ import { GB, accPath, gMid, gPath, stemSVG } from './glyphs';
4
+ import { STEP, noteName, pcOf } from '../music/notes';
5
+ import { clefDef, enabledClefs, staffNotesFor, staffPos } from '../music/clefs';
6
+ import { degPretty, noteText, scaleName, scaleNotes, type ScaleNote } from '../music/scales';
7
+ import { esc } from '../util/text';
8
+ import { masteryColour, monoLabel, staffLines } from './svg';
9
+
10
+ /* ---------------- clefs + staff ----------------
11
+ A clef says which staff position its origin sits on: the F line for the F
12
+ clef, the G line for the G clef, and whichever line a C clef is centred on. */
13
+ const CLEFG: Record<string, ['fClef' | 'gClef' | 'cClef', number]> = {
14
+ bass: ['fClef', 10],
15
+ treble: ['gClef', 30],
16
+ alto: ['cClef', 20],
17
+ tenor: ['cClef', 10],
18
+ };
19
+ /** Distance from the left edge of the staff to the clef. */
20
+ const CLEF_X = 3;
21
+ /** The staff's middle line: stems point away from it. */
22
+ const MIDDLE_LINE_Y = 20;
23
+ const BOTTOM_LINE_Y = 40;
24
+ const LINE_GAP = 10;
25
+ const ACCENT = 'var(--accent)';
26
+
27
+ function clefGlyph(clef?: string) {
28
+ return CLEFG[clef || 'bass'] || CLEFG.bass;
29
+ }
30
+ export function clefSVG(clef?: string): string {
31
+ const [glyph, y] = clefGlyph(clef);
32
+ return gPath(glyph, CLEF_X, y);
33
+ }
34
+ /** How wide the clef glyph is, so notes never collide with it. */
35
+ export function clefW(clef?: string): number {
36
+ return CLEF_X + GB[clefGlyph(clef)[0]][1];
37
+ }
38
+ function ledgerLine(y: number, noteX: number, halfWidth: number): string {
39
+ return `<line x1="${noteX - halfWidth}" y1="${y}" x2="${noteX + halfWidth}" y2="${y}" stroke="var(--staff-line)" stroke-width="1.3"/>`;
40
+ }
41
+ /** The ledger lines a note at height `y` needs, below or above the staff. */
42
+ export function ledgersFor(y: number, noteX: number, halfWidth: number): string {
43
+ const out: string[] = [];
44
+ if (y > BOTTOM_LINE_Y) {
45
+ const lowest = Math.floor(y / LINE_GAP) * LINE_GAP;
46
+ for (let ledgerY = BOTTOM_LINE_Y + LINE_GAP; ledgerY <= lowest; ledgerY += LINE_GAP) {
47
+ out.push(ledgerLine(ledgerY, noteX, halfWidth));
48
+ }
49
+ }
50
+ if (y < 0) {
51
+ const highest = Math.ceil(y / LINE_GAP) * LINE_GAP;
52
+ for (let ledgerY = -LINE_GAP; ledgerY >= highest; ledgerY -= LINE_GAP) {
53
+ out.push(ledgerLine(ledgerY, noteX, halfWidth));
54
+ }
55
+ }
56
+ return out.join('');
57
+ }
58
+ /** A notehead with its ledger lines, stem and accidental, in one colour. */
59
+ function noteWithStem(
60
+ noteX: number,
61
+ y: number,
62
+ alteration: number,
63
+ ledgerHalfWidth: number,
64
+ colour: string | null = null,
65
+ ): string {
66
+ return (
67
+ ledgersFor(y, noteX, ledgerHalfWidth) +
68
+ gMid('noteheadBlack', noteX, y, colour) +
69
+ stemSVG(noteX, y, MIDDLE_LINE_Y, colour) +
70
+ accPath(alteration, noteX, y, colour)
71
+ );
72
+ }
73
+
74
+ /** One note on a staff, for the reading drills. */
75
+ export function staffSVG(writtenMidi: number, pref: Pref, clef = 'bass'): string {
76
+ clef = clef || 'bass';
77
+ const pos = staffPos(writtenMidi, pref, clef);
78
+ const noteX = 86;
79
+ const staffWidth = 120;
80
+ return (
81
+ `<svg viewBox="-2 -46 124 118" role="img" aria-label="note on the ${clef} staff">` +
82
+ clefSVG(clef) +
83
+ staffLines(staffWidth) +
84
+ noteWithStem(noteX, pos.y, pos.a, 12) +
85
+ '</svg>'
86
+ );
87
+ }
88
+
89
+ /* One staff carrying a run of notes, each labelled in both systems. */
90
+ export const RUN = { x0: 52, dx: 44, H: 158, top: -48 };
91
+ /** Room after the last note. */
92
+ const RUN_END_MARGIN = 14;
93
+ /** The run is drawn a quarter larger than its viewBox. */
94
+ const RUN_SCALE = 1.25;
95
+ export function runX0(clef: string): number {
96
+ return Math.max(RUN.x0, clefW(clef) + 14);
97
+ }
98
+ export function runWidth(noteCount: number, clef: string): number {
99
+ return runX0(clef) + noteCount * RUN.dx + RUN_END_MARGIN;
100
+ }
101
+ export function staffRunSVG(
102
+ midis: number[],
103
+ pref: Pref,
104
+ selected: number,
105
+ showLabels: boolean,
106
+ clef = 'bass',
107
+ ): string {
108
+ clef = clef || 'bass';
109
+ const firstX = runX0(clef);
110
+ const width = runWidth(midis.length, clef);
111
+ const selectedIndex = midis.indexOf(selected);
112
+ const parts = [clefSVG(clef)];
113
+ /* the selected column goes down first, so the staff lines stay drawn across it */
114
+ if (selectedIndex >= 0) {
115
+ const columnX = firstX + selectedIndex * RUN.dx - 19;
116
+ parts.push(
117
+ `<rect x="${columnX}" y="-40" width="38" height="140" rx="7" fill="${ACCENT}" opacity="0.14"/>`,
118
+ );
119
+ }
120
+ parts.push(staffLines(width - 6));
121
+ midis.forEach((midi, k) => {
122
+ const pos = staffPos(midi, pref, clef);
123
+ const noteX = firstX + k * RUN.dx;
124
+ const isSelected = selected === midi;
125
+ parts.push(noteWithStem(noteX, pos.y, pos.a, 11, isSelected ? ACCENT : null));
126
+ if (showLabels !== false) {
127
+ const letter = noteName(pcOf(midi), 'en', pref);
128
+ const syllable = noteName(pcOf(midi), 'solf', pref);
129
+ parts.push(monoLabel(noteX, 80, 15, isSelected ? ACCENT : 'var(--ink)', letter, true));
130
+ parts.push(monoLabel(noteX, 97, 13, 'var(--ink-3)', syllable));
131
+ }
132
+ });
133
+ const size = `width="${Math.round(width * RUN_SCALE)}" height="${Math.round(RUN.H * RUN_SCALE)}"`;
134
+ return (
135
+ `<svg viewBox="-2 ${RUN.top} ${width} ${RUN.H}" ${size} role="img" aria-label="notes on the ${clef} staff">` +
136
+ parts.join('') +
137
+ '</svg>'
138
+ );
139
+ }
140
+
141
+ /* ---------------- a scale on the staff, for the degree pages and drills ----
142
+ One octave on the bass staff, spelled by letter (so F major gets B♭ even
143
+ when you have sharps selected), with the letter and the degree written
144
+ under each note. `mark` lists semitone offsets to pick out. */
145
+
146
+ /** A written octave for a root that keeps one octave of it on the bass staff
147
+ with no more than a ledger line or so either way: E2 up to D♯3. */
148
+ export function degRootWritten(pc: number): number {
149
+ const lowestRoot = 40; // E2
150
+ let midi = 36 + pc; // C2 + pc
151
+ while (midi < lowestRoot) {
152
+ midi += 12;
153
+ }
154
+ return midi;
155
+ }
156
+ export interface DegStaffOpts {
157
+ /** 'both' (letters and degrees, the default), 'letters', or 'none' — a
158
+ reading question must not print its own answer underneath. */
159
+ labels?: 'both' | 'letters' | 'none';
160
+ alter?: { k: number; dir: number } | null;
161
+ }
162
+ /** The scale's notes up to and including the octave, with one note moved
163
+ when `alter` asks for it (for the spot-the-wrong-note drill). */
164
+ function scaleWithOctave(
165
+ sid: string,
166
+ rootPc: number,
167
+ alter: { k: number; dir: number } | null | undefined,
168
+ ): ScaleNote[] {
169
+ const base = scaleNotes(sid, rootPc);
170
+ /* a few entries (root–fifth–octave) already list the octave themselves */
171
+ const notes: ScaleNote[] = base.some((note) => note.semis === 12)
172
+ ? base
173
+ : base.concat([{ semis: 12, pc: rootPc, deg: '8', l: base[0].l, a: base[0].a }]);
174
+ if (!alter) {
175
+ return notes;
176
+ }
177
+ return notes.map((note, k) =>
178
+ k !== alter.k
179
+ ? note
180
+ : {
181
+ semis: note.semis + alter.dir,
182
+ pc: pcOf(note.pc + alter.dir),
183
+ deg: '?',
184
+ l: note.l,
185
+ a: (note.a || 0) + alter.dir,
186
+ moved: true,
187
+ },
188
+ );
189
+ }
190
+ /** Height of the degree staff for each labelling: room for none, one or two rows. */
191
+ const DEG_STAFF_HEIGHT = { none: 112, letters: 128, both: 150 };
192
+ export function degStaffSVG(
193
+ sid: string,
194
+ rootPc: number,
195
+ rootWritten?: number | null,
196
+ mark?: number[] | null,
197
+ options: DegStaffOpts = {},
198
+ ): string {
199
+ const labels = options.labels || 'both';
200
+ const rootMidi = rootWritten ?? degRootWritten(rootPc);
201
+ const notes = scaleWithOctave(sid, rootPc, options.alter);
202
+ const clef = 'bass';
203
+ const bottomLineStep = clefDef(clef).bot;
204
+ /* A fixed width, wide enough for a seven-note scale and its octave. Sized
205
+ to the notes instead, a triad was drawn half again as large as a scale
206
+ beside it — and when three staves are offered side by side, the odd one
207
+ out by size is an answer given away. */
208
+ const firstX = clefW(clef) + 22;
209
+ const noteGap = 40;
210
+ const width = Math.max(firstX + notes.length * noteGap, firstX + 8 * noteGap);
211
+ const height = DEG_STAFF_HEIGHT[labels];
212
+ const parts = [clefSVG(clef), staffLines(width - 4)];
213
+ notes.forEach((note, k) => {
214
+ // the height comes from the letter, not the pitch: B♭ sits on the B line
215
+ const naturalMidi = rootMidi + note.semis - (note.a || 0);
216
+ const octave = Math.floor(naturalMidi / 12) - 1;
217
+ const diatonicIndex = octave * 7 + STEP[note.l];
218
+ const y = BOTTOM_LINE_Y - (diatonicIndex - bottomLineStep) * 5;
219
+ const noteX = firstX + k * noteGap;
220
+ const marked = !!mark && mark.includes(note.semis % 12) && note.semis < 12;
221
+ const colour = marked ? ACCENT : null;
222
+ if (marked) {
223
+ parts.push(
224
+ `<rect x="${noteX - 16}" y="-34" width="32" height="${height - 10}" rx="7" fill="${ACCENT}" opacity="0.13"/>`,
225
+ );
226
+ }
227
+ /* No natural sign on a moved note. These staves carry no key signature, so
228
+ a plain B already means B — and a natural sign would be the one mark no
229
+ real scale here ever shows, pointing straight at the wrong answer. */
230
+ parts.push(noteWithStem(noteX, y, note.a || 0, 11, colour));
231
+ if (labels !== 'none') {
232
+ parts.push(monoLabel(noteX, 80, 14, colour ?? 'var(--ink)', esc(noteText(note)), true));
233
+ }
234
+ if (labels === 'both') {
235
+ parts.push(monoLabel(noteX, 98, 13, colour ?? 'var(--ink-3)', degPretty(note.deg)));
236
+ }
237
+ });
238
+ const label =
239
+ labels === 'none'
240
+ ? 'a scale on the bass staff'
241
+ : esc(scaleName(sid, rootPc)) + ' with its degrees';
242
+ /* Scaled to the card rather than scrolled: a teaching picture whose last
243
+ note is hidden off to the right reads as a scale with a note missing. */
244
+ return (
245
+ `<div class="dfit"><svg viewBox="-2 -44 ${width + 4} ${height}" role="img" aria-label="${label}">` +
246
+ parts.join('') +
247
+ '</svg></div>'
248
+ );
249
+ }
250
+
251
+ /* ---------------- a staff heat map ----------------
252
+ The reading counterpart to the neck map. One notehead per note you can be
253
+ asked for on that clef, in pitch order, shaded by how solid it is. Pitch on
254
+ a staff is a vertical position, so the picture only means anything drawn on
255
+ a real staff — a row of squares would throw away the one thing the drill is
256
+ training. */
257
+ const HST = { dx: 30, H: 152, top: -52 };
258
+ const HEAT_SCALE = 1.1;
259
+ export function heatStaffSVG(clef: string): string {
260
+ const pref = honmoon.settings.pref;
261
+ const notes = staffNotesFor(clef as never);
262
+ if (!notes.length) {
263
+ return '';
264
+ }
265
+ const firstX = Math.max(36, clefW(clef) + 14);
266
+ const width = firstX + notes.length * HST.dx + 12;
267
+ const parts = [clefSVG(clef), staffLines(width - 8)];
268
+ notes.forEach((note, k) => {
269
+ const pos = staffPos(note.m, pref, clef);
270
+ const noteX = firstX + k * HST.dx;
271
+ const card = honmoon.cards[note.id];
272
+ const fill =
273
+ !card || !card.s ? 'var(--line-2)' : masteryColour(mastery(note.id), 'var(--surface)');
274
+ parts.push(ledgersFor(pos.y, noteX, 10));
275
+ parts.push(gMid('noteheadBlack', noteX, pos.y, fill));
276
+ parts.push(accPath(pos.a, noteX, pos.y, fill));
277
+ parts.push(monoLabel(noteX, 80, 12, 'var(--ink-3)', esc(noteName(pcOf(note.m), 'en', pref))));
278
+ });
279
+ const size = `width="${Math.round(width * HEAT_SCALE)}" height="${Math.round(HST.H * HEAT_SCALE)}"`;
280
+ return (
281
+ `<svg viewBox="-2 ${HST.top} ${width} ${HST.H}" ${size} role="img" aria-label="how solid each note on the ${clef} staff is">` +
282
+ parts.join('') +
283
+ '</svg>'
284
+ );
285
+ }
286
+ export function heatStaffClefs(): string[] {
287
+ return enabledClefs();
288
+ }
@@ -0,0 +1,37 @@
1
+ /* Small builders for the SVG strings the notation code assembles. They
2
+ return markup as text; it ends up in the page through {@html}. */
3
+
4
+ /** The five lines of a staff, 10 apart, from x = 0 to `width`. */
5
+ export function staffLines(width: number): string {
6
+ let out = '';
7
+ for (let line = 0; line < 5; line++) {
8
+ out += `<line x1="0" y1="${line * 10}" x2="${width}" y2="${line * 10}" stroke="var(--staff-line)" stroke-width="1.1"/>`;
9
+ }
10
+ return out;
11
+ }
12
+
13
+ /** A monospace label centred on x. */
14
+ export function monoLabel(
15
+ x: number,
16
+ y: number,
17
+ size: number,
18
+ fill: string,
19
+ text: string,
20
+ bold = false,
21
+ ): string {
22
+ const weight = bold ? ' font-weight="600"' : '';
23
+ return `<text x="${x}" y="${y}" text-anchor="middle" font-size="${size}"${weight} font-family="IBM Plex Mono,monospace" fill="${fill}">${text}</text>`;
24
+ }
25
+
26
+ /** The colour a mastery level is shown in on the heat maps: shaky (1 or less)
27
+ red, solid (5) green, and in between the "getting there" colour, mixed
28
+ stronger the higher the level, into the given background. */
29
+ export function masteryColour(level: number, background: string): string {
30
+ if (level <= 1) {
31
+ return 'var(--bad)';
32
+ }
33
+ if (level < 5) {
34
+ return `color-mix(in srgb,var(--mid) ${40 + level * 12}%, ${background})`;
35
+ }
36
+ return 'var(--good)';
37
+ }