@hraness/dawg 0.6.1 → 0.7.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 (253) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/DAWG.md +343 -116
  3. package/README.md +27 -25
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +271 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +182 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/instruments.ts +19 -0
  11. package/core/keys.ts +3 -3
  12. package/core/loop.ts +5 -0
  13. package/core/lyrics.ts +297 -0
  14. package/core/master.ts +3 -3
  15. package/core/resonators.ts +16 -2
  16. package/core/routing.ts +165 -0
  17. package/core/score.ts +832 -13
  18. package/core/sdk/eval-child.ts +7 -2
  19. package/core/sdk/eval.ts +35 -6
  20. package/core/sdk/print.ts +276 -3
  21. package/core/sdk/sync-lyrics.ts +49 -0
  22. package/core/sdk/v1.ts +1849 -42
  23. package/core/sections.ts +292 -26
  24. package/core/sing.ts +815 -0
  25. package/core/style-provenance.ts +80 -0
  26. package/core/styles/africa-mena-southasia.ts +2893 -0
  27. package/core/styles/americas.ts +3810 -0
  28. package/core/styles/art.ts +4993 -0
  29. package/core/styles/base.ts +123 -0
  30. package/core/styles/cycles.ts +106 -0
  31. package/core/styles/electronic.ts +2723 -0
  32. package/core/styles/europe-asia-pacific.ts +2838 -0
  33. package/core/styles/excerpt.ts +29 -0
  34. package/core/styles/gamelan.ts +283 -0
  35. package/core/styles/generate.ts +2199 -0
  36. package/core/styles/index.ts +515 -0
  37. package/core/styles/parts.ts +106 -0
  38. package/core/styles/pop.ts +3189 -0
  39. package/core/styles/rock.ts +2993 -0
  40. package/core/styles/roots.ts +4175 -0
  41. package/core/styles/schema.ts +429 -0
  42. package/core/styles/taxonomy.ts +940 -0
  43. package/core/styles/validate.ts +528 -0
  44. package/core/tempo.ts +32 -2
  45. package/core/tuning.ts +19 -3
  46. package/core/vocoder.ts +524 -0
  47. package/guides/agent.md +29 -0
  48. package/guides/arrange.md +30 -0
  49. package/guides/audition.md +20 -14
  50. package/guides/automation.md +12 -7
  51. package/guides/chords.md +15 -15
  52. package/guides/effects.md +17 -16
  53. package/guides/faders.md +20 -16
  54. package/guides/files.md +13 -9
  55. package/guides/getting-started.md +15 -11
  56. package/guides/keys.md +19 -15
  57. package/guides/media.md +17 -12
  58. package/guides/mix.md +12 -6
  59. package/guides/music.md +23 -8
  60. package/guides/notes.md +15 -9
  61. package/guides/performance.md +15 -13
  62. package/guides/play.md +21 -13
  63. package/guides/project.md +25 -8
  64. package/guides/providers.md +20 -14
  65. package/guides/resample.md +15 -9
  66. package/guides/rhythm.md +17 -13
  67. package/guides/sessions.md +17 -7
  68. package/guides/show-me.md +31 -0
  69. package/guides/sound.md +25 -9
  70. package/guides/sounds.md +15 -13
  71. package/guides/styles.md +31 -0
  72. package/guides/tempo.md +15 -10
  73. package/guides/tracks.md +15 -10
  74. package/guides/tuning.md +32 -0
  75. package/guides/voice.md +31 -0
  76. package/guides/web-search.md +18 -8
  77. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  78. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  79. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  80. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  81. package/native/prebuilt/manifest.json +21 -0
  82. package/package.json +5 -2
  83. package/src/agent/agent.ts +126 -14
  84. package/src/agent/calibration-tools.ts +53 -0
  85. package/src/agent/clip-tools.ts +453 -0
  86. package/src/agent/command-agent.ts +369 -0
  87. package/src/agent/drum-tools.ts +2 -2
  88. package/src/agent/expression-tools.ts +1 -1
  89. package/src/agent/gateway.ts +246 -60
  90. package/src/agent/models.ts +53 -12
  91. package/src/agent/ops.ts +12 -1
  92. package/src/agent/pack-tools.ts +1 -1
  93. package/src/agent/planner.ts +13 -0
  94. package/src/agent/portable-schema.ts +80 -0
  95. package/src/agent/preview-tool.ts +4 -1
  96. package/src/agent/provider.ts +22 -8
  97. package/src/agent/rhythm-tools.ts +1 -1
  98. package/src/agent/section-tools.ts +1 -1
  99. package/src/agent/show-me.ts +497 -0
  100. package/src/agent/steer.ts +15 -0
  101. package/src/agent/style-tools.ts +217 -0
  102. package/src/agent/tool-error.ts +12 -0
  103. package/src/agent/tools.ts +108 -23
  104. package/src/agent/usage.ts +2 -2
  105. package/src/agent/voice-tools.ts +925 -0
  106. package/src/agent/xcb-agent.ts +11 -7
  107. package/src/argv.ts +38 -0
  108. package/src/audio/analysis.ts +253 -0
  109. package/src/audio/arrange.ts +37 -3
  110. package/src/audio/autotune-engine.ts +101 -0
  111. package/src/audio/autotune.ts +640 -0
  112. package/src/audio/clips.ts +240 -0
  113. package/src/audio/doctor.ts +86 -0
  114. package/src/audio/dsp/bandbank.ts +138 -0
  115. package/src/audio/dsp/envelope.ts +10 -0
  116. package/src/audio/dsp/follow.ts +120 -0
  117. package/src/audio/dsp/formant.ts +427 -0
  118. package/src/audio/dsp/glottal.ts +243 -0
  119. package/src/audio/dsp/interp.ts +7 -2
  120. package/src/audio/dsp/lpc.ts +50 -0
  121. package/src/audio/dsp/periodicity.ts +59 -0
  122. package/src/audio/dsp/pitch.ts +995 -0
  123. package/src/audio/dsp/psola.ts +199 -0
  124. package/src/audio/effects/chain.ts +3 -1
  125. package/src/audio/effects/common.ts +43 -0
  126. package/src/audio/effects/convolution.ts +7 -4
  127. package/src/audio/effects/filter.ts +48 -69
  128. package/src/audio/effects/formant.ts +263 -0
  129. package/src/audio/engine.ts +160 -28
  130. package/src/audio/fit.ts +35 -3
  131. package/src/audio/instrument-check.ts +59 -43
  132. package/src/audio/instruments.ts +4 -0
  133. package/src/audio/keys/calibration.ts +56 -0
  134. package/src/audio/keys/electric.ts +8 -1
  135. package/src/audio/keys/engine.ts +13 -1
  136. package/src/audio/keys/piano.ts +22 -2
  137. package/src/audio/kits.ts +135 -6
  138. package/src/audio/live.ts +114 -20
  139. package/src/audio/native.ts +615 -0
  140. package/src/audio/preview.ts +30 -2
  141. package/src/audio/render-worker.ts +2 -0
  142. package/src/audio/renderer.ts +2 -0
  143. package/src/audio/resample.ts +2 -1
  144. package/src/audio/sampler.ts +85 -4
  145. package/src/audio/samples.ts +20 -2
  146. package/src/audio/sing/analysis.ts +193 -0
  147. package/src/audio/sing/engine.ts +949 -0
  148. package/src/audio/strings/bow.ts +48 -5
  149. package/src/audio/strings/engine.ts +8 -2
  150. package/src/audio/synth/oscillators.ts +31 -21
  151. package/src/audio/synth/voice.ts +34 -1
  152. package/src/audio/vocoder/bank.ts +314 -0
  153. package/src/audio/vocoder/carrier.ts +165 -0
  154. package/src/audio/vocoder/control.ts +68 -0
  155. package/src/audio/vocoder/detect.ts +50 -0
  156. package/src/audio/vocoder/index.ts +304 -0
  157. package/src/audio/vocoder/talkbox.ts +143 -0
  158. package/src/audio/wav.ts +500 -77
  159. package/src/audio/winds/engine.ts +5 -1
  160. package/src/audio/winds/trim.ts +28 -4
  161. package/src/audio/winds/trims1.ts +297 -0
  162. package/src/audio/winds/voice.ts +15 -2
  163. package/src/auth/cli.ts +38 -36
  164. package/src/auth/credentials.ts +30 -1
  165. package/src/auth/login.ts +15 -9
  166. package/src/auth/tui.ts +19 -10
  167. package/src/commands/arrange.ts +44 -29
  168. package/src/commands/autotune.ts +421 -0
  169. package/src/commands/calibration.ts +74 -0
  170. package/src/commands/clips.ts +887 -0
  171. package/src/commands/drums.ts +3 -2
  172. package/src/commands/edit.ts +11 -4
  173. package/src/commands/expression.ts +1 -1
  174. package/src/commands/formant.ts +221 -0
  175. package/src/commands/fx.ts +101 -32
  176. package/src/commands/grammar.ts +558 -0
  177. package/src/commands/help.ts +610 -380
  178. package/src/commands/history.ts +18 -0
  179. package/src/commands/keys.ts +8 -8
  180. package/src/commands/modal.ts +1 -1
  181. package/src/commands/music.ts +1 -1
  182. package/src/commands/nearest.ts +53 -0
  183. package/src/commands/pack.ts +9 -2
  184. package/src/commands/param-range.ts +56 -0
  185. package/src/commands/parses.ts +130 -0
  186. package/src/commands/progression.ts +170 -0
  187. package/src/commands/rhythm.ts +3 -0
  188. package/src/commands/rig.ts +3 -24
  189. package/src/commands/sing.ts +478 -0
  190. package/src/commands/strum.ts +13 -1
  191. package/src/commands/style.ts +415 -0
  192. package/src/commands/time.ts +6 -3
  193. package/src/commands/tuning.ts +3 -3
  194. package/src/commands/vocal-pitch.ts +616 -0
  195. package/src/commands/vocal.ts +147 -0
  196. package/src/commands/vocoder.ts +627 -0
  197. package/src/commands/wind.ts +2 -2
  198. package/src/fs/durable.ts +50 -0
  199. package/src/lang/glossary.ts +493 -0
  200. package/src/launch-args.ts +163 -0
  201. package/src/main.ts +1397 -253
  202. package/src/media/cli.ts +20 -3
  203. package/src/media/import.ts +3 -1
  204. package/src/project/check.ts +21 -2
  205. package/src/project/clip-pins.ts +72 -0
  206. package/src/project/init.ts +23 -8
  207. package/src/project/sync.ts +418 -86
  208. package/src/render.ts +20 -1
  209. package/src/session/daemon.ts +3 -0
  210. package/src/session/meta.ts +14 -0
  211. package/src/session/origin.ts +154 -0
  212. package/src/session/port.ts +22 -4
  213. package/src/session/presence.ts +34 -4
  214. package/src/session/protocol.ts +5 -1
  215. package/src/session/rebase.ts +18 -5
  216. package/src/session/receipt.ts +258 -0
  217. package/src/session/store.ts +65 -43
  218. package/src/tui/arrange-menu.ts +65 -31
  219. package/src/tui/audition.ts +1 -1
  220. package/src/tui/euclid.ts +18 -13
  221. package/src/tui/fader.ts +228 -41
  222. package/src/tui/granular-menu.ts +2 -4
  223. package/src/tui/menu-clips.ts +297 -0
  224. package/src/tui/menu-time.ts +20 -13
  225. package/src/tui/menu-voice.ts +405 -0
  226. package/src/tui/menu.ts +759 -177
  227. package/src/tui/modal-menu.ts +6 -6
  228. package/src/tui/performance-menu.ts +5 -2
  229. package/src/tui/play-chords.ts +4 -2
  230. package/src/tui/play-mode.ts +15 -1
  231. package/src/tui/play-session.ts +47 -7
  232. package/src/tui/sing-menu.ts +278 -0
  233. package/src/tui/style-menu.ts +104 -0
  234. package/src/tui/vocoder-menu.ts +244 -0
  235. package/src/tui/wind-menu.ts +3 -3
  236. package/src/version.ts +8 -0
  237. package/src/web/fetch.ts +115 -29
  238. package/tui/activity.ts +180 -9
  239. package/tui/app.ts +274 -40
  240. package/tui/clip-row.ts +132 -0
  241. package/tui/delight.ts +144 -0
  242. package/tui/drawer.ts +70 -22
  243. package/tui/frame-gate.ts +76 -0
  244. package/tui/grammar.ts +112 -63
  245. package/tui/guide.ts +42 -4
  246. package/tui/highway.ts +269 -25
  247. package/tui/hints.ts +192 -0
  248. package/tui/input.ts +60 -9
  249. package/tui/keys.ts +1 -1
  250. package/tui/play-strip.ts +68 -14
  251. package/tui/prompt.ts +1 -1
  252. package/tui/screen.ts +144 -14
  253. package/tui/theme.ts +27 -0
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Ctrl-k rows for the vocoder (0.7, vocoder.md §8): Effects › Voice ›
3
+ * Vocoder (Source picker, Preset, one row per VOCODER_PARAMS key in table
4
+ * order) and Voice › voice presets › Vocoder. Every row runs a
5
+ * `vocoder …` command, so it stages for the audition loop like any other.
6
+ */
7
+ import type { NumberParam } from "../../core/params.ts";
8
+ import type { Track } from "../../core/score.ts";
9
+ import {
10
+ DEFAULT_VOCODER_PRESET,
11
+ VOCODER_INSTRUMENT,
12
+ VOCODER_PARAMS,
13
+ VOCODER_PRESETS,
14
+ VOCODER_PRESET_NAMES,
15
+ resolveVocoder,
16
+ vocoderPresetOf,
17
+ type TrackVocoder,
18
+ } from "../../core/vocoder.ts";
19
+ import {
20
+ vocoderEntryLines,
21
+ trackHasAudio,
22
+ vocoderCandidates,
23
+ } from "../commands/vocoder.ts";
24
+ import { num, specStep, type MenuContext, type MenuNode } from "./menu.ts";
25
+
26
+ const NONE = "none";
27
+
28
+ function trackOf(context: MenuContext): Track | undefined {
29
+ return context.score.tracks.find((track) => track.id === context.trackId);
30
+ }
31
+
32
+ /** One row per VOCODER_PARAMS key; x and `preset` return it to the preset. */
33
+ function paramRow(vocoder: TrackVocoder, name: string): MenuNode {
34
+ const spec = VOCODER_PARAMS[name]!;
35
+ const own = (vocoder as Record<string, unknown>)[name];
36
+ const preset = vocoderPresetOf(vocoder);
37
+ const fallback = (VOCODER_PRESETS[preset].values as Record<string, unknown>)[
38
+ name
39
+ ];
40
+ const resolved = (resolveVocoder(vocoder) as Record<string, unknown>)[name];
41
+ if (spec.kind === "enum")
42
+ return {
43
+ kind: "choice",
44
+ label: name,
45
+ value: own === undefined ? "preset" : String(own),
46
+ options: ["preset", ...spec.values],
47
+ command: (option) =>
48
+ option === "preset"
49
+ ? `vocoder ${name} reset`
50
+ : `vocoder ${name} ${option}`,
51
+ help: `${spec.doc} · preset ${String(resolved)}`,
52
+ };
53
+ if (spec.kind === "boolean")
54
+ return {
55
+ kind: "choice",
56
+ label: name,
57
+ value: own === undefined ? "preset" : own ? "on" : "off",
58
+ options: ["preset", "on", "off"],
59
+ command: (option) =>
60
+ option === "preset"
61
+ ? `vocoder ${name} reset`
62
+ : `vocoder ${name} ${option}`,
63
+ help: `${spec.doc} · preset ${fallback ? "on" : "off"}`,
64
+ };
65
+ const number = spec as NumberParam;
66
+ // gate's preset value is "auto" (from the source's assets): show it as off.
67
+ const value = typeof resolved === "number" ? resolved : undefined;
68
+ return {
69
+ kind: "number",
70
+ label: name,
71
+ value,
72
+ min: number.min,
73
+ max: number.max,
74
+ step: specStep(number),
75
+ format: (v) => (number.unit ? `${num(v)} ${number.unit}` : num(v)),
76
+ command: (v) => `vocoder ${name} ${num(v)}`,
77
+ reset: `vocoder ${name} reset`,
78
+ ...(value === undefined ? { off: "auto", start: number.min } : {}),
79
+ help: `${number.doc} · preset ${typeof fallback === "number" ? num(fallback) : String(resolved ?? "auto")} · x resets`,
80
+ };
81
+ }
82
+
83
+ /** A source as the picker shows it: its name, or its id when names clash. */
84
+ function sourceLabel(track: Track, all: readonly Track[]): string {
85
+ const clash = all.some((t) => t.id !== track.id && t.name === track.name);
86
+ return clash || !track.name.trim() ? track.id : track.name;
87
+ }
88
+
89
+ /** `vocoder src <name>`, quoted when the name has spaces. */
90
+ function srcCommand(label: string): string {
91
+ return /\s/.test(label) ? `vocoder src "${label}"` : `vocoder src ${label}`;
92
+ }
93
+
94
+ function entryRows(): MenuNode[] {
95
+ return vocoderEntryLines().map((line, index) => ({
96
+ kind: "info",
97
+ label: index === 0 ? "no voice yet" : "",
98
+ value: line.trim(),
99
+ }));
100
+ }
101
+
102
+ /** The rows inside Effects › Voice › Vocoder for the focused track. */
103
+ export function vocoderRows(context: MenuContext): MenuNode[] {
104
+ const track = trackOf(context);
105
+ if (!track || track.instrument === "kit") return [];
106
+ const vocoder = track.vocoder;
107
+ const candidates = vocoderCandidates(context.score, track.id);
108
+ const all = context.score.tracks;
109
+ if (!vocoder) {
110
+ if (trackHasAudio(track))
111
+ return [
112
+ {
113
+ kind: "action",
114
+ label: "vocode",
115
+ command: "vocoder",
116
+ help: "adds a vocoder track driven by this voice (follows the song's chords, or drones on the key's root); mutes it",
117
+ },
118
+ ];
119
+ if (candidates.length === 0) return entryRows();
120
+ return [
121
+ {
122
+ kind: "choice",
123
+ label: "source",
124
+ value: NONE,
125
+ options: [NONE, ...candidates.map((t) => sourceLabel(t, all))],
126
+ command: (option) =>
127
+ option === NONE ? "vocoder off" : srcCommand(option),
128
+ help: "the voice that shapes this track's sound",
129
+ },
130
+ ];
131
+ }
132
+ const others = context.score.tracks.filter(
133
+ (t) => t.id !== track.id && t.instrument !== "kit",
134
+ );
135
+ const options = [
136
+ ...candidates.map((t) => sourceLabel(t, all)),
137
+ ...others
138
+ .filter((t) => !candidates.some((c) => c.id === t.id))
139
+ .map((t) => sourceLabel(t, all)),
140
+ ];
141
+ const current = all.find((t) => t.id === vocoder.src);
142
+ const nodes: MenuNode[] = [
143
+ {
144
+ kind: "choice",
145
+ label: "source",
146
+ value: current ? sourceLabel(current, all) : (vocoder.src ?? NONE),
147
+ options: vocoder.src === undefined ? [NONE, ...options] : options,
148
+ command: (option) => (option === NONE ? "vocoder" : srcCommand(option)),
149
+ help: "the modulator; it is heard through the vocoder even when muted",
150
+ },
151
+ {
152
+ kind: "choice",
153
+ label: "preset",
154
+ value: vocoder.preset ?? DEFAULT_VOCODER_PRESET,
155
+ options: VOCODER_PRESET_NAMES,
156
+ command: (option) => `vocoder preset ${option}`,
157
+ help: "classic robot talkbox choir glass whisper smear lofi · keeps your overrides",
158
+ },
159
+ ...Object.keys(VOCODER_PARAMS).map((name) => paramRow(vocoder, name)),
160
+ ];
161
+ if (Object.keys(vocoder).some((key) => key !== "preset" && key !== "src"))
162
+ nodes.push({
163
+ kind: "action",
164
+ label: "reset to preset",
165
+ command: "vocoder reset",
166
+ help: "clear overrides, keep the source and preset",
167
+ });
168
+ nodes.push({
169
+ kind: "action",
170
+ label: "remove the vocoder",
171
+ command: "vocoder off",
172
+ help: "the track plays its own sound again",
173
+ });
174
+ return nodes;
175
+ }
176
+
177
+ /** Effects › Voice › Vocoder. */
178
+ export function vocoderEffectNode(context: MenuContext): MenuNode[] {
179
+ const track = trackOf(context);
180
+ if (!track || track.instrument === "kit") return [];
181
+ const vocoder = track.vocoder;
182
+ return [
183
+ {
184
+ kind: "menu",
185
+ id: "voice:vocoder",
186
+ label: "vocoder",
187
+ detail: vocoder
188
+ ? `${vocoder.preset ?? DEFAULT_VOCODER_PRESET}${vocoder.src ? ` · from ${vocoder.src}` : " · no source"}`
189
+ : trackHasAudio(track)
190
+ ? "vocode"
191
+ : "off",
192
+ help: "a voice shapes this track's sound: robot, talkbox, choir",
193
+ build: vocoderRows,
194
+ },
195
+ ];
196
+ }
197
+
198
+ /** Voice › voice presets › Vocoder: the built-in carrier. */
199
+ export function vocoderBrowseNode(context: MenuContext): MenuNode[] {
200
+ const track = trackOf(context);
201
+ return [
202
+ {
203
+ kind: "menu",
204
+ id: "voices:vocoder",
205
+ label: "vocoder",
206
+ detail: `${VOCODER_PRESET_NAMES.length} presets · robot, talkbox, choir …`,
207
+ help: "a built-in synth carrier spoken through a voice track",
208
+ build: (inner) => {
209
+ const here = trackOf(inner) ?? track;
210
+ if (here && trackHasAudio(here))
211
+ return [
212
+ {
213
+ kind: "action",
214
+ label: "vocode",
215
+ command: "vocoder",
216
+ help: "adds a vocoder track driven by this voice",
217
+ },
218
+ ];
219
+ if (here?.instrument !== VOCODER_INSTRUMENT)
220
+ return [
221
+ {
222
+ kind: "action",
223
+ label: "make a vocoder",
224
+ command: `instrument ${VOCODER_INSTRUMENT}`,
225
+ help: "a supersaw carrier; then pick its presets and source here",
226
+ },
227
+ ...(vocoderCandidates(inner.score, inner.trackId).length === 0
228
+ ? entryRows()
229
+ : []),
230
+ ];
231
+ return VOCODER_PRESET_NAMES.map((name): MenuNode => ({
232
+ kind: "action",
233
+ label: `${name.padEnd(8)} ${VOCODER_PRESETS[name].doc}`,
234
+ command: `vocoder ${name}`,
235
+ help: VOCODER_PRESETS[name].doc,
236
+ })).concat(
237
+ vocoderCandidates(inner.score, inner.trackId).length === 0
238
+ ? entryRows()
239
+ : vocoderRows(inner).filter((row) => row.label === "source"),
240
+ );
241
+ },
242
+ },
243
+ ];
244
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Ctrl-k menu rows for the wind engine (0.6.1): the "Winds and brass" group
3
- * under Sound › browse sounds, and the WIND_PARAMS rows under Sound ›
3
+ * under Sound › instruments, and the WIND_PARAMS rows under Sound ›
4
4
  * Parameters on a wind-engine track. Every row runs a `wind …` command.
5
5
  */
6
6
  import type { EnumParam, NumberParam } from "../../core/params.ts";
@@ -27,12 +27,12 @@ function presetRow(name: WindPresetName): MenuNode {
27
27
  };
28
28
  }
29
29
 
30
- /** Sound › browse sounds › Winds and brass: flutes, reeds, brass. */
30
+ /** Sound › instruments › winds and brass: flutes, reeds, brass. */
31
31
  export function windsMenu(): MenuNode {
32
32
  return {
33
33
  kind: "menu",
34
34
  id: "winds",
35
- label: "Winds and brass",
35
+ label: "winds and brass",
36
36
  detail: `${WIND_PRESET_NAMES.length} blown · flute, sax, trumpet …`,
37
37
  help: "breath-driven waveguides: flutes, reeds and brass; wind players 4 for a section",
38
38
  build: () =>
package/src/version.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The package version, read from package.json by a static JSON import so
3
+ * `bun build --compile` embeds it: a runtime read relative to
4
+ * import.meta.url finds nothing inside a compiled binary.
5
+ */
6
+ import pkg from "../package.json" with { type: "json" };
7
+
8
+ export const VERSION: string = pkg.version;
package/src/web/fetch.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { lookup as dnsLookup } from "node:dns/promises";
9
9
  import { isIP } from "node:net";
10
+ import { checkServerIdentity, type PeerCertificate } from "node:tls";
10
11
  import {
11
12
  decodeEntities,
12
13
  describeFetchError,
@@ -81,26 +82,64 @@ function isPrivateV4(address: string): boolean {
81
82
  );
82
83
  }
83
84
 
84
- function isPrivateV6(address: string): boolean {
85
- const lower = address.toLowerCase().split("%")[0]!;
86
- if (lower === "::" || lower === "::1") return true;
87
- const mapped = /^(?:0*:)*:?ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
88
- if (mapped) return isPrivateV4(mapped[1]!);
89
- const hexMapped = /^(?:0*:)*:?ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(
90
- lower,
91
- );
92
- if (hexMapped) {
93
- const high = Number.parseInt(hexMapped[1]!, 16);
94
- const low = Number.parseInt(hexMapped[2]!, 16);
95
- return isPrivateV4(`${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`);
85
+ /** Expand an IPv6 address (zone stripped, embedded dotted IPv4 allowed) to 8 groups. */
86
+ function expandV6(address: string): number[] | undefined {
87
+ let text = address.toLowerCase().split("%")[0]!;
88
+ const dotted = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(text);
89
+ if (dotted) {
90
+ const [a, b, c, d] = dotted.slice(1).map(Number) as [
91
+ number,
92
+ number,
93
+ number,
94
+ number,
95
+ ];
96
+ text = `${text.slice(0, dotted.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
96
97
  }
97
- const first = Number.parseInt(lower.split(":")[0] || "0", 16);
98
+ const halves = text.split("::");
99
+ if (halves.length > 2) return undefined;
100
+ const parse = (part: string) =>
101
+ part === ""
102
+ ? []
103
+ : part.split(":").map((group) => Number.parseInt(group, 16));
104
+ const head = parse(halves[0]!);
105
+ const tail = halves.length === 2 ? parse(halves[1]!) : [];
106
+ const fill = 8 - head.length - tail.length;
107
+ if (halves.length === 1 ? fill !== 0 : fill < 1) return undefined;
108
+ const groups = [
109
+ ...head,
110
+ ...new Array<number>(halves.length === 2 ? fill : 0).fill(0),
111
+ ...tail,
112
+ ];
113
+ return groups.every((g) => Number.isInteger(g) && g >= 0 && g <= 0xffff)
114
+ ? groups
115
+ : undefined;
116
+ }
117
+
118
+ function v4From(high: number, low: number): string {
119
+ return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
120
+ }
121
+
122
+ function isPrivateV6(address: string): boolean {
123
+ const g = expandV6(address);
124
+ if (!g) return true;
125
+ const zero = (from: number, to: number) =>
126
+ g.slice(from, to).every((x) => x === 0);
127
+ // ::/96 covers :: and ::1 and the deprecated IPv4-compatible form (::a.b.c.d).
128
+ if (zero(0, 6)) return true;
129
+ // ::ffff:0:0/96 IPv4-mapped: as private as the address it carries.
130
+ if (zero(0, 5) && g[5] === 0xffff) return isPrivateV4(v4From(g[6]!, g[7]!));
131
+ // 2002::/16 6to4 embeds an IPv4 address in bits 16..47.
132
+ if (g[0] === 0x2002) return isPrivateV4(v4From(g[1]!, g[2]!));
133
+ const first = g[0]!;
98
134
  return (
135
+ (first === 0x64 && g[1] === 0xff9b) || // 64:ff9b::/96 and 64:ff9b:1::/48 NAT64
136
+ (first === 0x100 && zero(1, 4)) || // 100::/64 discard
137
+ (first === 0x2001 && g[1] === 0) || // 2001::/32 Teredo (obfuscated IPv4)
138
+ (first === 0x2001 && g[1] === 0xdb8) || // 2001:db8::/32 documentation
99
139
  (first & 0xfe00) === 0xfc00 || // fc00::/7 unique local
100
140
  (first & 0xffc0) === 0xfe80 || // fe80::/10 link-local
101
- (first & 0xff00) === 0xff00 || // ff00::/8 multicast
102
- lower.startsWith("64:ff9b:") || // NAT64 well-known prefix
103
- lower.startsWith("2001:db8:") // documentation
141
+ (first & 0xffc0) === 0xfec0 || // fec0::/10 deprecated site-local
142
+ (first & 0xff00) === 0xff00 // ff00::/8 multicast
104
143
  );
105
144
  }
106
145
 
@@ -136,12 +175,19 @@ export function admitUrl(raw: unknown): URL {
136
175
  return url;
137
176
  }
138
177
 
139
- async function assertPublicHost(url: URL, lookup: Lookup): Promise<void> {
178
+ /**
179
+ * Check that a URL's host is public and return the address to connect to.
180
+ * Every resolved address must be public; the first one is returned so the
181
+ * request connects to exactly the address that was checked, never to a
182
+ * second, independent DNS answer (which a rebinding domain could point at
183
+ * loopback or a metadata service).
184
+ */
185
+ async function assertPublicHost(url: URL, lookup: Lookup): Promise<string> {
140
186
  const host = url.hostname.replace(/^\[|\]$/g, "");
141
187
  if (isIP(host)) {
142
188
  if (isPrivateAddress(host))
143
189
  throw new WebError(`${host} is a private or local address`);
144
- return;
190
+ return host;
145
191
  }
146
192
  let addresses: readonly string[];
147
193
  try {
@@ -153,6 +199,42 @@ async function assertPublicHost(url: URL, lookup: Lookup): Promise<void> {
153
199
  for (const address of addresses)
154
200
  if (isPrivateAddress(address))
155
201
  throw new WebError(`${host} resolves to a private or local address`);
202
+ return addresses[0]!;
203
+ }
204
+
205
+ /** Bun's fetch accepts TLS options beyond the standard RequestInit. */
206
+ type PinnedInit = RequestInit & {
207
+ tls?: {
208
+ serverName?: string;
209
+ checkServerIdentity?: (
210
+ hostname: string,
211
+ cert: PeerCertificate,
212
+ ) => Error | undefined;
213
+ };
214
+ };
215
+
216
+ /**
217
+ * Address a request to a checked IP while keeping the original name for the
218
+ * Host header and for TLS (SNI and certificate verification), so the
219
+ * connection cannot be redirected by a later DNS answer.
220
+ */
221
+ export function pinRequest(
222
+ url: URL,
223
+ address: string,
224
+ ): { target: string; host: string; tls?: PinnedInit["tls"] } {
225
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
226
+ const pinned = new URL(url.toString());
227
+ pinned.hostname = isIP(address) === 6 ? `[${address}]` : address;
228
+ const result: { target: string; host: string; tls?: PinnedInit["tls"] } = {
229
+ target: pinned.toString(),
230
+ host: url.host,
231
+ };
232
+ if (url.protocol === "https:" && !isIP(hostname))
233
+ result.tls = {
234
+ serverName: hostname,
235
+ checkServerIdentity: (_name, cert) => checkServerIdentity(hostname, cert),
236
+ };
237
+ return result;
156
238
  }
157
239
 
158
240
  export async function fetchUrl(
@@ -168,18 +250,22 @@ export async function fetchUrl(
168
250
  let url = admitUrl(raw);
169
251
  let response: Response | undefined;
170
252
  for (let hop = 0; hop <= FETCH_LIMITS.maxRedirects; hop += 1) {
171
- await assertPublicHost(url, lookup);
253
+ const address = await assertPublicHost(url, lookup);
254
+ const pin = pinRequest(url, address);
255
+ const init: PinnedInit = {
256
+ method: "GET",
257
+ redirect: "manual",
258
+ headers: {
259
+ host: pin.host,
260
+ accept:
261
+ "text/html,application/xhtml+xml,text/plain;q=0.9,application/json;q=0.8,*/*;q=0.5",
262
+ "user-agent": "Mozilla/5.0 (compatible; dawg-agent)",
263
+ },
264
+ signal: timer.signal,
265
+ };
266
+ if (pin.tls) init.tls = pin.tls;
172
267
  try {
173
- response = await fetcher(url, {
174
- method: "GET",
175
- redirect: "manual",
176
- headers: {
177
- accept:
178
- "text/html,application/xhtml+xml,text/plain;q=0.9,application/json;q=0.8,*/*;q=0.5",
179
- "user-agent": "Mozilla/5.0 (compatible; dawg-agent)",
180
- },
181
- signal: timer.signal,
182
- });
268
+ response = await fetcher(pin.target, init);
183
269
  } catch (error) {
184
270
  throw new WebError(
185
271
  `fetch failed: ${timer.timedOut() ? `timed out after ${timeoutMs / 1000}s` : describeFetchError(error)}`,