@hraness/dawg 0.4.1 → 0.6.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 (166) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/DAWG.md +551 -33
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +41 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/fx.ts +437 -2
  8. package/core/granular.ts +528 -0
  9. package/core/instruments.ts +281 -0
  10. package/core/keys.ts +386 -0
  11. package/core/loop.ts +23 -0
  12. package/core/master.ts +455 -0
  13. package/core/midi.ts +452 -0
  14. package/core/resonators.ts +574 -0
  15. package/core/rhythm.ts +7 -2
  16. package/core/score.ts +717 -62
  17. package/core/sdk/eval-child.ts +40 -3
  18. package/core/sdk/print.ts +614 -27
  19. package/core/sdk/sync-instruments.ts +58 -0
  20. package/core/sdk/v1.ts +2824 -45
  21. package/core/sections.ts +2072 -0
  22. package/core/strings.ts +845 -0
  23. package/core/synth.ts +11 -1
  24. package/core/tempo.ts +1318 -0
  25. package/core/tuning.ts +1180 -0
  26. package/guides/audition.md +26 -0
  27. package/guides/automation.md +26 -0
  28. package/guides/chords.md +28 -0
  29. package/guides/effects.md +29 -0
  30. package/guides/faders.md +29 -0
  31. package/guides/files.md +28 -0
  32. package/guides/getting-started.md +26 -0
  33. package/guides/index.ts +75 -0
  34. package/guides/keys.md +27 -0
  35. package/guides/media.md +27 -0
  36. package/guides/mix.md +23 -0
  37. package/guides/music.md +14 -0
  38. package/guides/notes.md +26 -0
  39. package/guides/performance.md +26 -0
  40. package/guides/play.md +24 -0
  41. package/guides/project.md +13 -0
  42. package/guides/providers.md +25 -0
  43. package/guides/rhythm.md +26 -0
  44. package/guides/sessions.md +21 -0
  45. package/guides/sound.md +15 -0
  46. package/guides/sounds.md +29 -0
  47. package/guides/tempo.md +27 -0
  48. package/guides/tracks.md +25 -0
  49. package/guides/web-search.md +21 -0
  50. package/package.json +3 -1
  51. package/src/agent/agent.ts +9 -0
  52. package/src/agent/brief.ts +77 -3
  53. package/src/agent/chord-tools.ts +9 -1
  54. package/src/agent/expression-tools.ts +336 -0
  55. package/src/agent/granular-tools.ts +138 -0
  56. package/src/agent/master-tools.ts +299 -0
  57. package/src/agent/models.ts +4 -4
  58. package/src/agent/ops.ts +68 -6
  59. package/src/agent/planner.ts +45 -2
  60. package/src/agent/preview-tool.ts +27 -4
  61. package/src/agent/section-tools.ts +411 -0
  62. package/src/agent/time-tools.ts +290 -0
  63. package/src/agent/tools.ts +574 -16
  64. package/src/agent/tuning-tools.ts +301 -0
  65. package/src/agent/xcb-agent.ts +9 -0
  66. package/src/audio/arrange.ts +489 -0
  67. package/src/audio/audition.ts +16 -3
  68. package/src/audio/click.ts +113 -1
  69. package/src/audio/clock.ts +71 -5
  70. package/src/audio/dsp/bank.ts +233 -0
  71. package/src/audio/dsp/fft.ts +6 -0
  72. package/src/audio/dsp/filters.ts +57 -0
  73. package/src/audio/dsp/interp.ts +112 -0
  74. package/src/audio/dsp/modal.ts +684 -0
  75. package/src/audio/dsp/onset.ts +197 -0
  76. package/src/audio/dsp/oversample.ts +202 -0
  77. package/src/audio/dsp/rng.ts +37 -0
  78. package/src/audio/dsp/shape.ts +81 -0
  79. package/src/audio/dsp/stft.ts +73 -0
  80. package/src/audio/dsp/window.ts +77 -0
  81. package/src/audio/effects/bus.ts +5 -4
  82. package/src/audio/effects/chain.ts +6 -1
  83. package/src/audio/effects/common.ts +30 -1
  84. package/src/audio/effects/dynamics.ts +2 -1
  85. package/src/audio/effects/filter.ts +5 -3
  86. package/src/audio/effects/modulation.ts +5 -1
  87. package/src/audio/effects/rig/cab.ts +99 -0
  88. package/src/audio/effects/rig/filters.ts +152 -0
  89. package/src/audio/effects/rig/gate.ts +39 -0
  90. package/src/audio/effects/rig/head.ts +487 -0
  91. package/src/audio/effects/rig/index.ts +170 -0
  92. package/src/audio/effects/rig/section.ts +78 -0
  93. package/src/audio/effects/rig/stomp.ts +238 -0
  94. package/src/audio/effects/space.ts +92 -2
  95. package/src/audio/engine.ts +100 -21
  96. package/src/audio/fit.ts +402 -0
  97. package/src/audio/granular.ts +664 -0
  98. package/src/audio/instrument-check.ts +133 -0
  99. package/src/audio/instruments.ts +111 -0
  100. package/src/audio/keys/dsp.ts +323 -0
  101. package/src/audio/keys/engine.ts +361 -0
  102. package/src/audio/keys/piano.ts +432 -0
  103. package/src/audio/live-worker.ts +54 -0
  104. package/src/audio/live.ts +263 -17
  105. package/src/audio/loudness.ts +596 -0
  106. package/src/audio/master.ts +660 -0
  107. package/src/audio/measure-worker.ts +45 -0
  108. package/src/audio/measure.ts +110 -0
  109. package/src/audio/player.ts +11 -6
  110. package/src/audio/preview.ts +76 -14
  111. package/src/audio/render-worker.ts +6 -1
  112. package/src/audio/renderer.ts +7 -1
  113. package/src/audio/resonators.ts +287 -0
  114. package/src/audio/sampler.ts +269 -34
  115. package/src/audio/samples.ts +30 -7
  116. package/src/audio/strings/body.ts +250 -0
  117. package/src/audio/strings/engine.ts +369 -0
  118. package/src/audio/strings/loop.ts +119 -0
  119. package/src/audio/strings/measure.test-helpers.ts +198 -0
  120. package/src/audio/strings/pluck.ts +354 -0
  121. package/src/audio/synth/voice.ts +60 -13
  122. package/src/audio/synth/zzfx.ts +10 -4
  123. package/src/audio/warp.ts +147 -0
  124. package/src/audio/wav.ts +377 -30
  125. package/src/commands/arrange.ts +949 -0
  126. package/src/commands/expression.ts +941 -0
  127. package/src/commands/fit.ts +135 -0
  128. package/src/commands/fx.ts +31 -0
  129. package/src/commands/granular.ts +427 -0
  130. package/src/commands/help.ts +355 -27
  131. package/src/commands/keys.ts +353 -0
  132. package/src/commands/master.ts +361 -0
  133. package/src/commands/modal.ts +247 -0
  134. package/src/commands/music.ts +6 -1
  135. package/src/commands/rig.ts +260 -0
  136. package/src/commands/sample.ts +3 -0
  137. package/src/commands/string.ts +175 -0
  138. package/src/commands/synth.ts +11 -1
  139. package/src/commands/time.ts +967 -0
  140. package/src/commands/tuning.ts +490 -0
  141. package/src/main.ts +919 -41
  142. package/src/project/check.ts +13 -0
  143. package/src/render.ts +139 -8
  144. package/src/session/daemon.ts +18 -8
  145. package/src/session/naming.ts +8 -1
  146. package/src/session/rebase.ts +21 -0
  147. package/src/tui/arrange-menu.ts +390 -0
  148. package/src/tui/audition.ts +38 -5
  149. package/src/tui/fader.ts +409 -0
  150. package/src/tui/granular-menu.ts +278 -0
  151. package/src/tui/menu-time.ts +401 -0
  152. package/src/tui/menu.ts +967 -23
  153. package/src/tui/modal-menu.ts +118 -0
  154. package/src/tui/performance-menu.ts +235 -0
  155. package/src/tui/play-chords.ts +3 -1
  156. package/src/tui/play-mode.ts +150 -6
  157. package/src/tui/play-session.ts +459 -43
  158. package/tui/app.ts +262 -13
  159. package/tui/arrange-strip.ts +174 -0
  160. package/tui/drawer.ts +478 -0
  161. package/tui/grammar.ts +63 -1
  162. package/tui/guide.ts +351 -0
  163. package/tui/highway.ts +87 -4
  164. package/tui/hits.ts +68 -0
  165. package/tui/input.ts +7 -0
  166. package/tui/keys.ts +72 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Ctrl-k menu rows for modal percussion (0.6): the "Mallets and bells"
3
+ * group under Sound › browse sounds, and the MODAL_PARAMS rows under
4
+ * Sound › Parameters on a modal track. Every row runs a `modal …` command.
5
+ */
6
+ import type { EnumParam, NumberParam } from "../../core/params.ts";
7
+ import {
8
+ MODAL_INSTRUMENT,
9
+ MODAL_MALLET_NAMES,
10
+ MODAL_PARAMS,
11
+ MODAL_PRESETS,
12
+ MODAL_PRESET_NAMES,
13
+ MODAL_SIMPLE,
14
+ modalSettings,
15
+ } from "../../core/resonators.ts";
16
+ import type { Track } from "../../core/score.ts";
17
+ import { num, specStep, type MenuNode } from "./menu.ts";
18
+
19
+ /** Sound › browse sounds › Mallets and bells: one row per preset. */
20
+ export function malletsMenu(): MenuNode {
21
+ return {
22
+ kind: "menu",
23
+ id: "mallets",
24
+ label: "Mallets and bells",
25
+ detail: `${MODAL_PRESET_NAMES.length} modal presets · marimba, vibes, gong …`,
26
+ help: "struck bars, bells and bowls on the modal resonator engine",
27
+ build: () =>
28
+ MODAL_PRESET_NAMES.map((name): MenuNode => ({
29
+ kind: "action",
30
+ label: `${name} ${MODAL_PRESETS[name].doc}`,
31
+ command: `modal ${name}`,
32
+ help: MODAL_PRESETS[name].styles,
33
+ })),
34
+ };
35
+ }
36
+
37
+ function numberRow(
38
+ name: string,
39
+ spec: NumberParam,
40
+ value: number,
41
+ fallback: number,
42
+ ): MenuNode {
43
+ return {
44
+ kind: "number",
45
+ label: name,
46
+ value,
47
+ min: spec.min,
48
+ max: spec.max,
49
+ step: specStep(spec),
50
+ format: (v) => (spec.unit ? `${num(v)} ${spec.unit}` : num(v)),
51
+ command: (v) => `modal ${name} ${num(v)}`,
52
+ reset: `modal ${name} off`,
53
+ help: `${spec.doc} · preset ${num(fallback)} · x resets`,
54
+ };
55
+ }
56
+
57
+ /** Sound › Parameters rows on a modal track. */
58
+ export function modalParameterNodes(track: Track): MenuNode[] {
59
+ if (track.instrument !== MODAL_INSTRUMENT) return [];
60
+ const modal = track.modal;
61
+ const preset = MODAL_PRESETS[modal?.preset ?? "marimba"].settings;
62
+ const resolved = modalSettings(modal) as Readonly<
63
+ Record<string, number | string>
64
+ >;
65
+ const row = (name: string): MenuNode | undefined => {
66
+ const spec = MODAL_PARAMS[name]!;
67
+ if (name === "mallet")
68
+ return {
69
+ kind: "choice",
70
+ label: "mallet",
71
+ value: modal?.mallet ?? "preset",
72
+ options: ["preset", ...MODAL_MALLET_NAMES],
73
+ command: (option) =>
74
+ option === "preset" ? "modal mallet off" : `modal mallet ${option}`,
75
+ help: (spec as EnumParam).doc,
76
+ };
77
+ if (spec.kind !== "number") return undefined;
78
+ return numberRow(
79
+ name,
80
+ spec,
81
+ Number(resolved[name]),
82
+ Number(preset[name as keyof typeof preset]),
83
+ );
84
+ };
85
+ const nodes: MenuNode[] = [
86
+ {
87
+ kind: "choice",
88
+ label: "preset",
89
+ value: modal?.preset ?? "marimba",
90
+ options: MODAL_PRESET_NAMES,
91
+ command: (option) => `modal preset ${option}`,
92
+ help: "the instrument: mode table, mallet and ring",
93
+ },
94
+ ];
95
+ for (const name of MODAL_SIMPLE) {
96
+ const node = row(name);
97
+ if (node) nodes.push(node);
98
+ }
99
+ const rest = Object.keys(MODAL_PARAMS).filter(
100
+ (name) => !(MODAL_SIMPLE as readonly string[]).includes(name),
101
+ );
102
+ nodes.push({
103
+ kind: "menu",
104
+ id: "modal:advanced",
105
+ label: "advanced",
106
+ detail: `motor, ombak, buzz, click, strike glide, gain`,
107
+ help: "every modal parameter",
108
+ build: () => rest.flatMap((name) => row(name) ?? []),
109
+ });
110
+ if (modal && Object.keys(modal).some((key) => key !== "preset"))
111
+ nodes.push({
112
+ kind: "action",
113
+ label: "reset to preset",
114
+ command: "modal reset",
115
+ help: "clear overrides, keep the preset",
116
+ });
117
+ return nodes;
118
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Sound › Performance: how the focused track plays its notes. Every row
3
+ * runs the same prompt command a user could type (src/commands/expression.ts),
4
+ * so the menu, the prompt and the agent share one grammar.
5
+ */
6
+ import {
7
+ ARTICULATIONS,
8
+ DEFAULT_FIXED_VELOCITY,
9
+ DEFAULT_GLIDE_SECONDS,
10
+ EXPRESSION_LIMITS,
11
+ GLIDE_MODES,
12
+ VELOCITY_CURVES,
13
+ } from "../../core/expression.ts";
14
+ import type { Note, Track } from "../../core/score.ts";
15
+ import {
16
+ BEND_SHAPES,
17
+ DEFAULT_HUMANIZE,
18
+ describeHumanize,
19
+ parseExpressionCommand,
20
+ } from "../commands/expression.ts";
21
+ import type { MenuContext, MenuNode } from "./menu.ts";
22
+
23
+ const round = (value: number, places = 3) =>
24
+ Math.round(value * 10 ** places) / 10 ** places;
25
+
26
+ /** One value every note shares, `mixed`, or `fallback` when none set it. */
27
+ function shared(
28
+ notes: readonly Note[],
29
+ read: (note: Note) => string | undefined,
30
+ fallback: string,
31
+ ): string {
32
+ const values = new Set(notes.map((note) => read(note) ?? fallback));
33
+ if (values.size === 0) return fallback;
34
+ return values.size === 1 ? [...values][0]! : "mixed";
35
+ }
36
+
37
+ function bendName(note: Note): string | undefined {
38
+ if (!note.bend) return undefined;
39
+ const text = JSON.stringify(note.bend);
40
+ return (
41
+ Object.entries(BEND_SHAPES).find(
42
+ ([, points]) => JSON.stringify(points) === text,
43
+ )?.[0] ?? "custom"
44
+ );
45
+ }
46
+
47
+ /** The menu's one-line summary of a track's performance, for its row. */
48
+ export function performanceDetail(track: Track | undefined): string {
49
+ if (!track) return "—";
50
+ const parts = [
51
+ track.glide ? `glide ${track.glide.mode}` : "",
52
+ track.pedal?.length ? "pedal" : "",
53
+ track.velocityCurve ? `vel ${track.velocityCurve.curve}` : "",
54
+ track.humanize ? "humanized" : "",
55
+ ].filter(Boolean);
56
+ return parts.length ? parts.join(" · ") : "as written";
57
+ }
58
+
59
+ export function performanceNodes(context: MenuContext): MenuNode[] {
60
+ const track = context.score.tracks.find(
61
+ (item) => item.id === context.trackId,
62
+ );
63
+ if (!track) return [];
64
+ const notes = context.score.notes.filter((note) => note.trackId === track.id);
65
+ const glide = track.glide;
66
+ const curve = track.velocityCurve;
67
+ const humanize = track.humanize;
68
+ const humanizeCommand = (timing: number, velocity: number, length: number) =>
69
+ `humanize ${round(timing)} ${round(velocity)} ${round(length)}`;
70
+ const timing = humanize?.timing ?? 0;
71
+ const velocity = humanize?.velocity ?? 0;
72
+ const length = humanize?.length ?? 0;
73
+ const pedalLabel = track.pedal?.length
74
+ ? `${track.pedal.length} events`
75
+ : "off";
76
+ const nodes: MenuNode[] = [
77
+ {
78
+ kind: "choice",
79
+ label: "articulation",
80
+ value: shared(notes, (note) => note.articulation, "none"),
81
+ options: ["none", ...ARTICULATIONS],
82
+ command: (option) => `art ${option === "none" ? "off" : option}`,
83
+ help: "every note on the track: staccato halves the length, ghost plays quiet",
84
+ },
85
+ {
86
+ kind: "number",
87
+ label: "glide (ms)",
88
+ value: glide?.time,
89
+ min: 0,
90
+ max: EXPRESSION_LIMITS.maxGlideSeconds,
91
+ step: (value, direction) =>
92
+ Math.min(
93
+ EXPRESSION_LIMITS.maxGlideSeconds,
94
+ Math.max(0, round(value + 0.01 * direction, 2)),
95
+ ),
96
+ format: (value) => `${Math.round(value * 1000)} ms`,
97
+ command: (value) => `glide ${Math.round(value * 1000)}ms`,
98
+ off: "off",
99
+ start: DEFAULT_GLIDE_SECONDS,
100
+ reset: "glide off",
101
+ help: "portamento into each note from the one before",
102
+ },
103
+ {
104
+ kind: "choice",
105
+ label: "glide mode",
106
+ value: glide?.mode ?? "off",
107
+ options: ["off", ...GLIDE_MODES],
108
+ command: (option) => (option === "off" ? "glide off" : `glide ${option}`),
109
+ help: "legato: slide only into overlapping notes (TB-303); mono: always; poly: every voice",
110
+ },
111
+ {
112
+ kind: "choice",
113
+ label: "bend",
114
+ value: shared(notes, bendName, "none"),
115
+ options: ["none", ...Object.keys(BEND_SHAPES)],
116
+ command: (option) => `bend ${option === "none" ? "off" : option}`,
117
+ help: "a pitch curve over every note; `bend` in the prompt takes points",
118
+ },
119
+ {
120
+ kind: "entry",
121
+ label: "vibrato",
122
+ value: shared(
123
+ notes,
124
+ (note) =>
125
+ note.vibrato
126
+ ? `${note.vibrato.rate} Hz ${note.vibrato.depth}¢`
127
+ : undefined,
128
+ "none",
129
+ ),
130
+ placeholder: "rate Hz, depth cents, delay s: 5.5 25 0.2",
131
+ command: (text) => {
132
+ const command = text.trim() ? `vibrato ${text.trim()}` : "vibrato off";
133
+ return parseExpressionCommand(command) ? command : undefined;
134
+ },
135
+ example: "vibrato 5.5 25 0.2",
136
+ help: "replaces the synth's vib on every note on the track",
137
+ },
138
+ {
139
+ kind: "choice",
140
+ label: "sustain pedal",
141
+ value: pedalLabel,
142
+ options: ["off", ...(pedalLabel === "off" ? [] : [pedalLabel]), "bars"],
143
+ command: (option) =>
144
+ option === "bars"
145
+ ? "pedal bars"
146
+ : option === "off"
147
+ ? "pedal off"
148
+ : "pedal",
149
+ help: "bars replaces the lane with a re-pedal on each downbeat; in play mode press Tab (latch) or hold Shift while recording",
150
+ },
151
+ {
152
+ kind: "choice",
153
+ label: "velocity curve",
154
+ value: curve?.curve ?? "linear",
155
+ options: VELOCITY_CURVES,
156
+ command: (option) => `velcurve ${option}`,
157
+ help: "soft lifts quiet notes, hard deepens them, fixed plays every note the same",
158
+ },
159
+ ];
160
+ if (curve?.curve === "fixed")
161
+ nodes.push({
162
+ kind: "number",
163
+ label: "fixed velocity",
164
+ value: curve.fixed ?? DEFAULT_FIXED_VELOCITY,
165
+ min: 0,
166
+ max: 1,
167
+ step: (value, direction) =>
168
+ Math.min(1, Math.max(0, round(value + 0.05 * direction, 2))),
169
+ format: (value) => String(round(value, 2)),
170
+ command: (value) => `velcurve fixed ${round(value, 2)}`,
171
+ reset: `velcurve fixed ${DEFAULT_FIXED_VELOCITY}`,
172
+ });
173
+ nodes.push(
174
+ {
175
+ kind: "number",
176
+ label: "humanize timing (ms)",
177
+ value: humanize ? timing : undefined,
178
+ min: 0,
179
+ max: EXPRESSION_LIMITS.maxHumanizeTimingMs,
180
+ step: (value, direction) =>
181
+ Math.min(
182
+ EXPRESSION_LIMITS.maxHumanizeTimingMs,
183
+ Math.max(0, value + direction),
184
+ ),
185
+ format: (value) => `±${round(value)} ms`,
186
+ command: (value) => humanizeCommand(value, velocity, length),
187
+ off: "off",
188
+ start: DEFAULT_HUMANIZE.timing,
189
+ reset: "humanize off",
190
+ help: `seeded drift applied at render; the score stays clean (${describeHumanize(humanize)})`,
191
+ },
192
+ {
193
+ kind: "number",
194
+ label: "humanize velocity (%)",
195
+ value: humanize ? velocity : undefined,
196
+ min: 0,
197
+ max: EXPRESSION_LIMITS.maxHumanizePercent,
198
+ step: (value, direction) =>
199
+ Math.min(
200
+ EXPRESSION_LIMITS.maxHumanizePercent,
201
+ Math.max(0, value + direction),
202
+ ),
203
+ format: (value) => `±${round(value)}%`,
204
+ command: (value) => humanizeCommand(timing, value, length),
205
+ off: "off",
206
+ start: DEFAULT_HUMANIZE.velocity,
207
+ reset: "humanize off",
208
+ },
209
+ {
210
+ kind: "number",
211
+ label: "humanize length (%)",
212
+ value: humanize ? length : undefined,
213
+ min: 0,
214
+ max: EXPRESSION_LIMITS.maxHumanizePercent,
215
+ step: (value, direction) =>
216
+ Math.min(
217
+ EXPRESSION_LIMITS.maxHumanizePercent,
218
+ Math.max(0, value + direction),
219
+ ),
220
+ format: (value) => `±${round(value)}%`,
221
+ command: (value) => humanizeCommand(timing, velocity, value),
222
+ off: "off",
223
+ start: 5,
224
+ reset: "humanize off",
225
+ },
226
+ );
227
+ if (humanize)
228
+ nodes.push({
229
+ kind: "action",
230
+ label: `new take (seed ${humanize.seed})`,
231
+ command: "humanize reseed",
232
+ help: "the same amounts with a different seeded performance",
233
+ });
234
+ return nodes;
235
+ }
@@ -22,6 +22,7 @@
22
22
  *
23
23
  * Pure: the session asks for a chord per note key and owns sound and time.
24
24
  */
25
+ import { resolveTuning, snapToTuning } from "../../core/tuning.ts";
25
26
  import {
26
27
  BASS_MODES,
27
28
  CHORD_PATTERNS,
@@ -610,6 +611,7 @@ export function chordPhrase(
610
611
  ? rendered.bass.map((note) => ({ ...note, pitch: note.pitch + 24 }))
611
612
  : [...rendered.notes, ...rendered.bass];
612
613
  const end = totalBeats * tpb;
614
+ const table = resolveTuning(score.tuning, track.tuning, score.key);
613
615
  const notes: NoteInput[] = [];
614
616
  for (const note of performed) {
615
617
  const startTick = Math.round(note.start * tpb);
@@ -622,7 +624,7 @@ export function chordPhrase(
622
624
  1,
623
625
  Math.min(Math.round(note.length * tpb), end - startTick),
624
626
  ),
625
- pitch: note.pitch,
627
+ pitch: snapToTuning(note.pitch, table),
626
628
  velocity: note.velocity,
627
629
  });
628
630
  }
@@ -10,6 +10,8 @@
10
10
  *
11
11
  * Z / X octave down / up C / V velocity down / up (steps of 16)
12
12
  * Shift sustain while held Tab sustain latch Esc leave the mode
13
+ * (while recording, sustain changes record as track pedal events)
14
+ * I scale degrees: the home row plays the key's scale (any tuning)
13
15
  *
14
16
  * Terminals report key-down only, so a held key is synthesized from the
15
17
  * keyboard's auto-repeat: a press sounds for the gate (one grid step by
@@ -21,12 +23,14 @@
21
23
  *
22
24
  * Everything here is pure: callers pass the monotonic time of each key.
23
25
  */
26
+ import { keyName, parseKey, scaleSteps } from "../../core/chords.ts";
24
27
  import {
25
28
  SAMPLER_FIRST_SLOT,
26
29
  isSamplerInstrument,
27
30
  samplerVoiceSlots,
28
31
  type Track,
29
32
  } from "../../core/score.ts";
33
+ import type { TuningTable } from "../../core/tuning.ts";
30
34
  import { pitchName } from "../../tui/highway.ts";
31
35
 
32
36
  /** Semitone offset from the base C for each note key. */
@@ -148,8 +152,86 @@ export function rangeLabel(base: number): string {
148
152
  return `${pitchName(base)}–${pitchName(base + LAYOUT_SPAN)}`;
149
153
  }
150
154
 
155
+ /** The home row left to right: consecutive degrees in degree mode. */
156
+ export const HOME_ROW: readonly string[] = Object.freeze(
157
+ Object.keys(WHITE_KEYS),
158
+ );
159
+
160
+ /**
161
+ * Scale-degree layout (Push's "In Key", Komplete Kontrol's Easy mode): the
162
+ * home row plays consecutive degrees from the tonic and the upper row is
163
+ * off, so every key is in the scale.
164
+ */
165
+ export type DegreeLayout = Readonly<{
166
+ /** Key offsets of the degrees within one period, ascending from 0. */
167
+ steps: readonly number[];
168
+ /** Keys per period: 12, or a linear tuning's step count. */
169
+ period: number;
170
+ /** Key of degree 0 with the keyboard at its default base (C3). */
171
+ tonic: number;
172
+ /** `D dorian`, `19-edo`, `C major · 19-edo`. */
173
+ name: string;
174
+ }>;
175
+
176
+ /**
177
+ * The degrees the home row plays for a song key and the track's tuning. A
178
+ * twelve-key tuning (12-TET, just intonation, a maqam preset) plays the
179
+ * key's scale on the twelve keys, quarter tones on the key their preset
180
+ * retunes; a linear non-12 tuning (19-EDO, pelog) plays every step, or the
181
+ * nearest step to each scale note when a key is set. No key reads C major.
182
+ */
183
+ export function degreeLayout(
184
+ keyText: string | null | undefined,
185
+ table: TuningTable | undefined,
186
+ ): DegreeLayout {
187
+ const key = parseKey(keyText ?? undefined);
188
+ if (table && table.linear && table.size !== 12) {
189
+ const period = table.size;
190
+ const pc = ((table.root % 12) + 12) % 12;
191
+ const tonic =
192
+ table.root + Math.round((DEFAULT_BASE + pc - table.root) / 12) * period;
193
+ const at = (offset: number) =>
194
+ table.hz[Math.max(0, Math.min(127, tonic + offset))]!;
195
+ const degreeCents = Array.from({ length: period }, (_, offset) =>
196
+ at(offset) > 0 && at(0) > 0 ? 1200 * Math.log2(at(offset) / at(0)) : NaN,
197
+ );
198
+ const steps = key
199
+ ? [
200
+ ...new Set(
201
+ scaleSteps(key).map((step) => {
202
+ let best = 0;
203
+ for (let offset = 1; offset < period; offset += 1)
204
+ if (
205
+ Math.abs(degreeCents[offset]! - step * 100) <
206
+ Math.abs(degreeCents[best]! - step * 100)
207
+ )
208
+ best = offset;
209
+ return best;
210
+ }),
211
+ ),
212
+ ].sort((a, b) => a - b)
213
+ : degreeCents.map((_, offset) => offset);
214
+ return Object.freeze({
215
+ steps: Object.freeze(steps),
216
+ period,
217
+ tonic,
218
+ name: key ? `${keyName(key)} · ${table.name}` : table.name,
219
+ });
220
+ }
221
+ const chosen = key ?? { tonic: 0, mode: "major" as const };
222
+ const steps = [
223
+ ...new Set(scaleSteps(chosen).map((step) => Math.ceil(step) % 12)),
224
+ ].sort((a, b) => a - b);
225
+ return Object.freeze({
226
+ steps: Object.freeze(steps),
227
+ period: 12,
228
+ tonic: DEFAULT_BASE + chosen.tonic,
229
+ name: keyName(chosen),
230
+ });
231
+ }
232
+
151
233
  export type PlayCommand =
152
- "exit" | "record" | "replace" | "click" | "transport" | "menu";
234
+ "exit" | "record" | "replace" | "click" | "transport" | "menu" | "degrees";
153
235
 
154
236
  /** Keys that drive the mode itself rather than notes. */
155
237
  const COMMAND_KEYS: Readonly<Record<string, PlayCommand>> = Object.freeze({
@@ -158,6 +240,7 @@ const COMMAND_KEYS: Readonly<Record<string, PlayCommand>> = Object.freeze({
158
240
  R: "replace",
159
241
  m: "click",
160
242
  M: "click",
243
+ i: "degrees",
161
244
  " ": "transport",
162
245
  "\u000b": "menu",
163
246
  });
@@ -225,6 +308,27 @@ export class PlayKeyboard {
225
308
  private nextId = 1;
226
309
  /** Shift was held on the last note key (BandLab's Sustain light). */
227
310
  public shiftHeld = false;
311
+ private layout: DegreeLayout | undefined;
312
+ /**
313
+ * Degrees the home row is shifted by when a period has more steps than
314
+ * the home row has keys (19- or 31-EDO): Z and X page by a row instead of
315
+ * a period, so every step stays reachable.
316
+ */
317
+ public degreeOffset = 0;
318
+
319
+ /** Scale-degree layout (`i` in play mode); undefined is chromatic. */
320
+ public get degrees(): DegreeLayout | undefined {
321
+ return this.layout;
322
+ }
323
+
324
+ public set degrees(layout: DegreeLayout | undefined) {
325
+ if (
326
+ layout?.steps.length !== this.layout?.steps.length ||
327
+ layout?.period !== this.layout?.period
328
+ )
329
+ this.degreeOffset = 0;
330
+ this.layout = layout;
331
+ }
228
332
 
229
333
  public constructor(options: PlayKeyboardOptions = {}) {
230
334
  this.base = clampBase(options.base ?? DEFAULT_BASE);
@@ -236,12 +340,30 @@ export class PlayKeyboard {
236
340
  }
237
341
 
238
342
  public get range(): string {
239
- return rangeLabel(this.base);
343
+ if (!this.degrees) return rangeLabel(this.base);
344
+ const low = this.pitchFor(HOME_ROW[0]!);
345
+ const high = this.pitchFor(HOME_ROW[HOME_ROW.length - 1]!);
346
+ return low === undefined || high === undefined
347
+ ? "out of range"
348
+ : `${pitchName(low)}–${pitchName(high)}`;
240
349
  }
241
350
 
242
351
  /** Pitch a note key plays at the current octave, or undefined. */
243
352
  public pitchFor(key: string): number | undefined {
244
- const offset = NOTE_KEYS[SHIFTED[key] ?? key.toLowerCase()];
353
+ const name = SHIFTED[key] ?? key.toLowerCase();
354
+ if (this.degrees) {
355
+ const index = HOME_ROW.indexOf(name);
356
+ if (index < 0) return undefined;
357
+ const { steps, period, tonic } = this.degrees;
358
+ const degree = this.degreeOffset + index;
359
+ const octave = Math.floor(degree / steps.length);
360
+ const pitch =
361
+ tonic +
362
+ ((this.base - DEFAULT_BASE) / 12 + octave) * period +
363
+ steps[degree - octave * steps.length]!;
364
+ return pitch >= MIN_PITCH && pitch <= MAX_PITCH ? pitch : undefined;
365
+ }
366
+ const offset = NOTE_KEYS[name];
245
367
  if (offset === undefined) return undefined;
246
368
  return this.base + offset;
247
369
  }
@@ -271,8 +393,25 @@ export class PlayKeyboard {
271
393
  if (command) return { type: "command", command };
272
394
  const lower = value.length === 1 ? value.toLowerCase() : value;
273
395
  if (lower === "z" || lower === "x") {
396
+ if (this.degrees && this.degrees.steps.length > HOME_ROW.length) {
397
+ // Page by a home row of degrees; stop before the row falls silent.
398
+ const previous = this.degreeOffset;
399
+ this.degreeOffset += lower === "z" ? -HOME_ROW.length : HOME_ROW.length;
400
+ const clamped = HOME_ROW.every(
401
+ (key) => this.pitchFor(key) === undefined,
402
+ );
403
+ if (clamped) this.degreeOffset = previous;
404
+ return { type: "octave", base: this.base, clamped };
405
+ }
274
406
  const wanted = this.base + (lower === "z" ? -12 : 12);
407
+ const previous = this.base;
275
408
  this.base = clampBase(wanted);
409
+ // Degree mode moves a period; stop before the home row falls silent.
410
+ if (
411
+ this.degrees &&
412
+ HOME_ROW.every((key) => this.pitchFor(key) === undefined)
413
+ )
414
+ this.base = previous;
276
415
  return { type: "octave", base: this.base, clamped: wanted !== this.base };
277
416
  }
278
417
  if (lower === "c" || lower === "v") {
@@ -428,10 +567,15 @@ export function stripCells(
428
567
  lit: ReadonlySet<string>,
429
568
  labels: ReadonlyMap<number, string> = new Map(),
430
569
  ): StripCell[] {
431
- return STRIP_ORDER.map((key) => {
432
- const pitch = keyboard.pitchFor(key)!;
570
+ const order = keyboard.degrees ? HOME_ROW : STRIP_ORDER;
571
+ return order.map((key) => {
572
+ const pitch = keyboard.pitchFor(key);
433
573
  const name =
434
- labels.size > 0 ? (labels.get(pitch) ?? "·") : pitchName(pitch);
574
+ pitch === undefined
575
+ ? "·"
576
+ : labels.size > 0
577
+ ? (labels.get(pitch) ?? "·")
578
+ : pitchName(pitch);
435
579
  return {
436
580
  key,
437
581
  label: name,