@hraness/dawg 0.2.0 → 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 (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
@@ -0,0 +1,203 @@
1
+ // Vendored from soundfish `lib/song-import/grid.ts` (same owner); the protocol constants it imported are local here.
2
+ import type { BeatGrid } from "../types.ts";
3
+ import { clamp, finiteNumber, isRecord, parseJson } from "./util.ts";
4
+
5
+ const MAX_BEATS = 20_000;
6
+ const MAX_DURATION_SECONDS = 7_200;
7
+ export const FIXED_GRID_DETECTOR = "dawg-fixed-grid";
8
+
9
+ /** Validates a StemDeck `/api/jobs/<id>/beats` body from `unknown`. */
10
+ export function parseBeatGrid(bytes: Uint8Array): BeatGrid {
11
+ const value = parseJson(bytes, "StemDeck beat grid");
12
+ if (!isRecord(value) || !Array.isArray(value.beats)) {
13
+ throw new Error("StemDeck beat grid did not contain a beats array.");
14
+ }
15
+ if (value.beats.length < 2 || value.beats.length > MAX_BEATS) {
16
+ throw new Error(`StemDeck beat grid must contain 2–${MAX_BEATS} beats.`);
17
+ }
18
+ const beats = value.beats.map((entry, index) => {
19
+ const beat = finiteNumber(entry);
20
+ if (beat === undefined || beat < 0 || beat > MAX_DURATION_SECONDS) {
21
+ throw new Error(
22
+ `StemDeck beat ${index} is outside the supported time range.`,
23
+ );
24
+ }
25
+ return beat;
26
+ });
27
+ for (let index = 1; index < beats.length; index += 1) {
28
+ if (beats[index]! <= beats[index - 1]!) {
29
+ throw new Error("StemDeck beat times must be strictly increasing.");
30
+ }
31
+ }
32
+ const duration = finiteNumber(value.duration);
33
+ const lastBeat = beats.at(-1)!;
34
+ const fallbackDuration = Math.min(
35
+ MAX_DURATION_SECONDS,
36
+ lastBeat + (lastBeat - beats.at(-2)!),
37
+ );
38
+ const durationSeconds =
39
+ duration === undefined
40
+ ? fallbackDuration
41
+ : clamp(duration, lastBeat, MAX_DURATION_SECONDS);
42
+ const bars = Array.isArray(value.bars)
43
+ ? value.bars.slice(0, 2_000).flatMap((entry) => {
44
+ if (!isRecord(entry)) return [];
45
+ const beat = finiteNumber(entry.beat);
46
+ const beatsPerBar = finiteNumber(
47
+ entry.beats_per_bar ?? entry.beatsPerBar,
48
+ );
49
+ if (
50
+ beat === undefined ||
51
+ beatsPerBar === undefined ||
52
+ !Number.isSafeInteger(beat) ||
53
+ !Number.isSafeInteger(beatsPerBar) ||
54
+ beat < 0 ||
55
+ beat >= beats.length ||
56
+ beatsPerBar < 1 ||
57
+ beatsPerBar > 32
58
+ ) {
59
+ return [];
60
+ }
61
+ return [{ beat, beatsPerBar }];
62
+ })
63
+ : [];
64
+ const bpm = finiteNumber(value.bpm);
65
+ const confidence = finiteNumber(value.confidence);
66
+ const intervalCv = finiteNumber(value.interval_cv);
67
+ return {
68
+ beats,
69
+ bars,
70
+ durationSeconds,
71
+ ...(bpm === undefined ? {} : { bpm }),
72
+ ...(typeof value.detector === "string"
73
+ ? { detector: value.detector.slice(0, 80) }
74
+ : {}),
75
+ ...(confidence === undefined
76
+ ? {}
77
+ : { confidence: clamp(confidence, 0, 100) }),
78
+ intervalCv:
79
+ intervalCv === undefined
80
+ ? beatIntervalCv(beats)
81
+ : clamp(intervalCv, 0, 10),
82
+ };
83
+ }
84
+
85
+ export function beatIntervalCv(beats: readonly number[]): number {
86
+ if (beats.length < 3) return 0;
87
+ const intervals = beats
88
+ .slice(1)
89
+ .map((beat, index) => beat - beats[index]!)
90
+ .filter((value) => value > 0);
91
+ if (intervals.length < 2) return 0;
92
+ const mean =
93
+ intervals.reduce((sum, value) => sum + value, 0) / intervals.length;
94
+ const variance =
95
+ intervals.reduce((sum, value) => sum + (value - mean) ** 2, 0) /
96
+ intervals.length;
97
+ return mean <= 0 ? 0 : Math.sqrt(variance) / mean;
98
+ }
99
+
100
+ function edgeInterval(beats: readonly number[], atEnd: boolean): number {
101
+ const count = Math.min(8, beats.length - 1);
102
+ let sum = 0;
103
+ for (let offset = 0; offset < count; offset += 1) {
104
+ const index = atEnd ? beats.length - 2 - offset : offset;
105
+ sum += beats[index + 1]! - beats[index]!;
106
+ }
107
+ return sum / count;
108
+ }
109
+
110
+ /** Piecewise-linear source seconds to the global beat axis (beat 0 = first beat). */
111
+ export function secondsToBeat(
112
+ grid: Pick<BeatGrid, "beats">,
113
+ seconds: number,
114
+ ): number {
115
+ const beats = grid.beats;
116
+ if (beats.length < 2 || !Number.isFinite(seconds)) {
117
+ throw new Error("Cannot map time without a valid beat grid.");
118
+ }
119
+ if (seconds <= beats[0]!) {
120
+ return (seconds - beats[0]!) / edgeInterval(beats, false);
121
+ }
122
+ const lastIndex = beats.length - 1;
123
+ if (seconds >= beats[lastIndex]!) {
124
+ return (
125
+ lastIndex + (seconds - beats[lastIndex]!) / edgeInterval(beats, true)
126
+ );
127
+ }
128
+ let low = 0;
129
+ let high = lastIndex;
130
+ while (low + 1 < high) {
131
+ const middle = Math.floor((low + high) / 2);
132
+ if (beats[middle]! <= seconds) low = middle;
133
+ else high = middle;
134
+ }
135
+ const interval = beats[low + 1]! - beats[low]!;
136
+ return low + (seconds - beats[low]!) / interval;
137
+ }
138
+
139
+ /** Inverse of secondsToBeat on the same piecewise-linear beat axis. */
140
+ export function beatToSeconds(
141
+ grid: Pick<BeatGrid, "beats">,
142
+ beat: number,
143
+ ): number {
144
+ const beats = grid.beats;
145
+ if (beats.length < 2 || !Number.isFinite(beat)) {
146
+ throw new Error("Cannot map beats without a valid beat grid.");
147
+ }
148
+ const lastIndex = beats.length - 1;
149
+ if (beat <= 0) return beats[0]! + beat * edgeInterval(beats, false);
150
+ if (beat >= lastIndex) {
151
+ return beats[lastIndex]! + (beat - lastIndex) * edgeInterval(beats, true);
152
+ }
153
+ const index = Math.floor(beat);
154
+ const fraction = beat - index;
155
+ return beats[index]! + fraction * (beats[index + 1]! - beats[index]!);
156
+ }
157
+
158
+ export function gridMedianBpm(grid: Pick<BeatGrid, "beats" | "bpm">): number {
159
+ if (grid.bpm !== undefined && Number.isFinite(grid.bpm)) {
160
+ return clamp(Math.round(grid.bpm), 40, 240);
161
+ }
162
+ const intervals = grid.beats
163
+ .slice(1)
164
+ .map((beat, index) => beat - grid.beats[index]!)
165
+ .filter((interval) => interval > 0)
166
+ .sort((left, right) => left - right);
167
+ if (intervals.length === 0) return 120;
168
+ const middle = Math.floor(intervals.length / 2);
169
+ const median =
170
+ intervals.length % 2 === 0
171
+ ? (intervals[middle - 1]! + intervals[middle]!) / 2
172
+ : intervals[middle]!;
173
+ return clamp(Math.round(60 / median), 40, 240);
174
+ }
175
+
176
+ /** A metronomic grid at `bpm` from `offsetSeconds`, 4 beats per bar. */
177
+ export function fixedBeatGrid(
178
+ bpm: number,
179
+ durationSeconds: number,
180
+ offsetSeconds = 0,
181
+ ): BeatGrid {
182
+ const interval = 60 / clamp(bpm, 40, 240);
183
+ const duration = clamp(durationSeconds, interval * 2, MAX_DURATION_SECONDS);
184
+ const count = Math.min(
185
+ MAX_BEATS,
186
+ Math.max(2, Math.ceil(duration / interval)),
187
+ );
188
+ const beats: number[] = [];
189
+ const start = clamp(offsetSeconds, 0, interval);
190
+ for (let index = 0; index < count; index += 1)
191
+ beats.push(start + index * interval);
192
+ const bars: { beat: number; beatsPerBar: number }[] = [];
193
+ for (let beat = 0; beat < count; beat += 4)
194
+ bars.push({ beat, beatsPerBar: 4 });
195
+ return {
196
+ beats,
197
+ bars,
198
+ durationSeconds: duration,
199
+ bpm,
200
+ detector: FIXED_GRID_DETECTOR,
201
+ intervalCv: 0,
202
+ };
203
+ }
@@ -0,0 +1,139 @@
1
+ // Vendored from soundfish `lib/song-import/util.ts` (same owner); trimmed to what dawg's media tools use.
2
+ const encoder = new TextEncoder();
3
+ const decoder = new TextDecoder("utf-8", { fatal: true });
4
+
5
+ export function decodeUtf8(bytes: Uint8Array, label: string): string {
6
+ try {
7
+ return decoder.decode(bytes);
8
+ } catch {
9
+ throw new Error(`${label} was not valid UTF-8.`);
10
+ }
11
+ }
12
+
13
+ export function parseJson(bytes: Uint8Array, label: string): unknown {
14
+ const text = decodeUtf8(bytes, label);
15
+ try {
16
+ return JSON.parse(text) as unknown;
17
+ } catch {
18
+ throw new Error(`${label} was not valid JSON.`);
19
+ }
20
+ }
21
+
22
+ export function finiteNumber(value: unknown): number | undefined {
23
+ return typeof value === "number" && Number.isFinite(value)
24
+ ? value
25
+ : undefined;
26
+ }
27
+
28
+ export function optionalString(
29
+ value: unknown,
30
+ maximumLength = 1_000,
31
+ ): string | undefined {
32
+ if (typeof value !== "string") return undefined;
33
+ const trimmed = value.trim();
34
+ return trimmed.length > 0 && trimmed.length <= maximumLength
35
+ ? trimmed
36
+ : undefined;
37
+ }
38
+
39
+ function boundedUtf8(
40
+ value: string,
41
+ maximumBytes: number,
42
+ fallback: string,
43
+ ): string {
44
+ const wellFormed = new TextDecoder().decode(new TextEncoder().encode(value));
45
+ const normalized = wellFormed
46
+ .replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
47
+ .replace(/\s+/gu, " ")
48
+ .trim();
49
+ if (!normalized) return fallback;
50
+ if (encoder.encode(normalized).byteLength <= maximumBytes) return normalized;
51
+ let result = "";
52
+ for (const character of normalized) {
53
+ if (encoder.encode(`${result}${character}`).byteLength > maximumBytes)
54
+ break;
55
+ result += character;
56
+ }
57
+ return result.trim() || fallback;
58
+ }
59
+
60
+ /** Safe for reflecting untrusted service/process text into a terminal line. */
61
+ export function terminalSafeText(
62
+ value: string,
63
+ maximumBytes = 2_000,
64
+ fallback = "External tool error",
65
+ ): string {
66
+ return boundedUtf8(value.replace(/[‪-‮⁦-⁩]/gu, " "), maximumBytes, fallback);
67
+ }
68
+
69
+ export function clamp(value: number, minimum: number, maximum: number): number {
70
+ return Math.max(minimum, Math.min(maximum, value));
71
+ }
72
+
73
+ export function fileBaseName(path: string): string {
74
+ return path.replace(/\\/gu, "/").split("/").at(-1) ?? path;
75
+ }
76
+
77
+ /** Absolute HTTP(S) URL without credentials, query, or fragment. */
78
+ export function normalizeServiceBaseUrl(value: string): URL {
79
+ if (encoder.encode(value).byteLength > 2_048) {
80
+ throw new Error("DAWG_STEMDECK_URL must be at most 2048 UTF-8 bytes.");
81
+ }
82
+ let parsed: URL;
83
+ try {
84
+ parsed = new URL(value);
85
+ } catch {
86
+ throw new Error("DAWG_STEMDECK_URL must be an absolute HTTP(S) URL.");
87
+ }
88
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
89
+ throw new Error("DAWG_STEMDECK_URL must use HTTP or HTTPS.");
90
+ }
91
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) {
92
+ throw new Error(
93
+ "DAWG_STEMDECK_URL cannot include credentials, a query, or a fragment.",
94
+ );
95
+ }
96
+ parsed.pathname = parsed.pathname.replace(/\/+$/gu, "");
97
+ return parsed;
98
+ }
99
+
100
+ /**
101
+ * Accepts YouTube hosts only and strips credentials and the fragment, so the
102
+ * value is safe to log and to hand to yt-dlp or StemDeck.
103
+ */
104
+ export function validateYoutubeUrl(value: string): string {
105
+ if (value.length > 2_048) throw new Error("The YouTube URL is too long.");
106
+ let parsed: URL;
107
+ try {
108
+ parsed = new URL(value);
109
+ } catch {
110
+ throw new Error("The input must be an absolute YouTube URL.");
111
+ }
112
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
113
+ throw new Error("The YouTube URL must use HTTP or HTTPS.");
114
+ }
115
+ const host = parsed.hostname.toLowerCase();
116
+ const accepted =
117
+ host === "youtu.be" ||
118
+ host === "youtube.com" ||
119
+ host.endsWith(".youtube.com") ||
120
+ host === "youtube-nocookie.com" ||
121
+ host.endsWith(".youtube-nocookie.com");
122
+ if (!accepted) throw new Error("The input host is not YouTube.");
123
+ parsed.username = "";
124
+ parsed.password = "";
125
+ parsed.hash = "";
126
+ return parsed.toString();
127
+ }
128
+
129
+ export function isRecord(value: unknown): value is Record<string, unknown> {
130
+ return typeof value === "object" && value !== null && !Array.isArray(value);
131
+ }
132
+
133
+ export function errorMessage(error: unknown): string {
134
+ try {
135
+ return error instanceof Error ? error.message : String(error);
136
+ } catch {
137
+ return "Unknown error";
138
+ }
139
+ }
@@ -0,0 +1,233 @@
1
+ // Vendored from soundfish `lib/audio-media/wav.ts` (same owner): RIFF/WAVE parser for PCM 16/24/32 and float32.
2
+
3
+ export type WavFailureCode = "unsupported_media" | "decode_failed" | "too_long";
4
+
5
+ export class WavFormatError extends Error {
6
+ readonly code: WavFailureCode;
7
+
8
+ constructor(code: WavFailureCode, message: string) {
9
+ super(message);
10
+ this.name = "WavFormatError";
11
+ this.code = code;
12
+ }
13
+ }
14
+
15
+ export type WavEncoding = "pcm16" | "pcm24" | "pcm32" | "float32";
16
+
17
+ export type ParsedWav = Readonly<{
18
+ sampleRate: number;
19
+ channels: number;
20
+ /** Frame count: one frame carries one sample per channel. */
21
+ sampleCount: number;
22
+ encoding: WavEncoding;
23
+ sample: (frame: number, channel: number) => number;
24
+ }>;
25
+
26
+ export type ParseWavOptions = Readonly<{
27
+ /** Whole-buffer ceiling; larger inputs fail as `too_long`. Default 256 MiB. */
28
+ maximumBytes?: number;
29
+ /** Duration ceiling from the header; default two hours. */
30
+ maximumDurationSeconds?: number;
31
+ maximumChannels?: number;
32
+ }>;
33
+
34
+ export const WAV_DEFAULT_MAXIMUM_BYTES = 256 * 1024 * 1024;
35
+ export const WAV_DEFAULT_MAXIMUM_DURATION_SECONDS = 7_200;
36
+ export const WAV_DEFAULT_MAXIMUM_CHANNELS = 8;
37
+ const WAV_MINIMUM_SAMPLE_RATE = 8_000;
38
+ const WAV_MAXIMUM_SAMPLE_RATE = 192_000;
39
+
40
+ const FORMAT_PCM = 0x0001;
41
+ const FORMAT_IEEE_FLOAT = 0x0003;
42
+ const FORMAT_EXTENSIBLE = 0xfffe;
43
+ const MINIMUM_FMT_BYTES = 16;
44
+ const EXTENSIBLE_FMT_BYTES = 40;
45
+ const DATA_SIZE_UNKNOWN = 0xffffffff;
46
+ /** Trailing 14 bytes of KSDATAFORMAT_SUBTYPE_PCM / _IEEE_FLOAT after the 16-bit tag. */
47
+ const SUBFORMAT_GUID_SUFFIX = [
48
+ 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b,
49
+ 0x71,
50
+ ];
51
+
52
+ function ascii(bytes: Uint8Array, offset: number, count: number): string {
53
+ return String.fromCharCode(...bytes.subarray(offset, offset + count));
54
+ }
55
+
56
+ function clampUnit(value: number): number {
57
+ return value < -1 ? -1 : value > 1 ? 1 : value;
58
+ }
59
+
60
+ type FormatChunk = Readonly<{
61
+ audioFormat: number;
62
+ channels: number;
63
+ sampleRate: number;
64
+ blockAlign: number;
65
+ bits: number;
66
+ }>;
67
+
68
+ function readFormatChunk(
69
+ bytes: Uint8Array,
70
+ view: DataView,
71
+ start: number,
72
+ size: number,
73
+ ): FormatChunk {
74
+ if (size < MINIMUM_FMT_BYTES)
75
+ throw new WavFormatError("decode_failed", "WAV has a short format chunk.");
76
+ let audioFormat = view.getUint16(start, true);
77
+ if (audioFormat === FORMAT_EXTENSIBLE) {
78
+ if (size < EXTENSIBLE_FMT_BYTES || view.getUint16(start + 16, true) < 22) {
79
+ throw new WavFormatError(
80
+ "unsupported_media",
81
+ "WAV extensible format chunk is incomplete.",
82
+ );
83
+ }
84
+ const subFormat = start + 24;
85
+ const suffixMatches = SUBFORMAT_GUID_SUFFIX.every(
86
+ (byte, index) => bytes[subFormat + 2 + index] === byte,
87
+ );
88
+ if (!suffixMatches)
89
+ throw new WavFormatError(
90
+ "unsupported_media",
91
+ "WAV SubFormat is not PCM or IEEE float.",
92
+ );
93
+ audioFormat = view.getUint16(subFormat, true);
94
+ }
95
+ return {
96
+ audioFormat,
97
+ channels: view.getUint16(start + 2, true),
98
+ sampleRate: view.getUint32(start + 4, true),
99
+ blockAlign: view.getUint16(start + 12, true),
100
+ bits: view.getUint16(start + 14, true),
101
+ };
102
+ }
103
+
104
+ function encodingFor(format: FormatChunk): WavEncoding | undefined {
105
+ if (format.audioFormat === FORMAT_PCM) {
106
+ if (format.bits === 16) return "pcm16";
107
+ if (format.bits === 24) return "pcm24";
108
+ if (format.bits === 32) return "pcm32";
109
+ return undefined;
110
+ }
111
+ if (format.audioFormat === FORMAT_IEEE_FLOAT && format.bits === 32)
112
+ return "float32";
113
+ return undefined;
114
+ }
115
+
116
+ export function parseWav(
117
+ bytes: Uint8Array,
118
+ options: ParseWavOptions = {},
119
+ ): ParsedWav {
120
+ const maximumBytes = options.maximumBytes ?? WAV_DEFAULT_MAXIMUM_BYTES;
121
+ const maximumDurationSeconds =
122
+ options.maximumDurationSeconds ?? WAV_DEFAULT_MAXIMUM_DURATION_SECONDS;
123
+ const maximumChannels =
124
+ options.maximumChannels ?? WAV_DEFAULT_MAXIMUM_CHANNELS;
125
+ if (bytes.byteLength < 44 || bytes.byteLength > maximumBytes) {
126
+ const megabytes = Math.floor(maximumBytes / (1024 * 1024));
127
+ throw new WavFormatError(
128
+ bytes.byteLength < 44 ? "decode_failed" : "too_long",
129
+ `WAV is missing or exceeds the ${megabytes} MiB analysis limit.`,
130
+ );
131
+ }
132
+ if (ascii(bytes, 0, 4) !== "RIFF" || ascii(bytes, 8, 4) !== "WAVE") {
133
+ throw new WavFormatError(
134
+ "unsupported_media",
135
+ "File is not a RIFF/WAVE file.",
136
+ );
137
+ }
138
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
139
+ let offset = 12;
140
+ let format: FormatChunk | undefined;
141
+ let dataOffset = -1;
142
+ let dataBytes = 0;
143
+ while (offset + 8 <= bytes.byteLength) {
144
+ const id = ascii(bytes, offset, 4);
145
+ const size = view.getUint32(offset + 4, true);
146
+ const start = offset + 8;
147
+ if (id === "data" && size === DATA_SIZE_UNKNOWN) {
148
+ throw new WavFormatError(
149
+ "unsupported_media",
150
+ "WAV data chunk has an unknown length.",
151
+ );
152
+ }
153
+ const end = start + size;
154
+ if (end > bytes.byteLength)
155
+ throw new WavFormatError(
156
+ "decode_failed",
157
+ "WAV contains a truncated chunk.",
158
+ );
159
+ if (id === "fmt ") {
160
+ format = readFormatChunk(bytes, view, start, size);
161
+ } else if (id === "data" && dataOffset < 0) {
162
+ dataOffset = start;
163
+ dataBytes = size;
164
+ }
165
+ offset = end + (size & 1);
166
+ }
167
+ if (!format || dataOffset < 0)
168
+ throw new WavFormatError(
169
+ "decode_failed",
170
+ "WAV is missing format or audio data.",
171
+ );
172
+ if (
173
+ format.channels < 1 ||
174
+ format.channels > maximumChannels ||
175
+ format.sampleRate < WAV_MINIMUM_SAMPLE_RATE ||
176
+ format.sampleRate > WAV_MAXIMUM_SAMPLE_RATE
177
+ ) {
178
+ throw new WavFormatError(
179
+ "unsupported_media",
180
+ "WAV channel count or sample rate is unsupported.",
181
+ );
182
+ }
183
+ const encoding = encodingFor(format);
184
+ if (!encoding)
185
+ throw new WavFormatError(
186
+ "unsupported_media",
187
+ "WAV must be PCM16/24/32 or IEEE float32.",
188
+ );
189
+ const bytesPerSample = format.bits / 8;
190
+ const blockAlign = format.blockAlign;
191
+ if (
192
+ blockAlign !== bytesPerSample * format.channels ||
193
+ dataBytes % blockAlign !== 0
194
+ ) {
195
+ throw new WavFormatError(
196
+ "decode_failed",
197
+ "WAV has an inconsistent block alignment.",
198
+ );
199
+ }
200
+ const sampleCount = dataBytes / blockAlign;
201
+ if (sampleCount / format.sampleRate > maximumDurationSeconds) {
202
+ throw new WavFormatError(
203
+ "too_long",
204
+ `WAV exceeds the ${maximumDurationSeconds} second analysis limit.`,
205
+ );
206
+ }
207
+ const sample = (frame: number, channel: number): number => {
208
+ const position = dataOffset + frame * blockAlign + channel * bytesPerSample;
209
+ switch (encoding) {
210
+ case "float32":
211
+ return clampUnit(view.getFloat32(position, true));
212
+ case "pcm16":
213
+ return view.getInt16(position, true) / 32_768;
214
+ case "pcm24": {
215
+ let value =
216
+ view.getUint8(position) |
217
+ (view.getUint8(position + 1) << 8) |
218
+ (view.getUint8(position + 2) << 16);
219
+ if (value & 0x800000) value |= 0xff000000;
220
+ return value / 8_388_608;
221
+ }
222
+ case "pcm32":
223
+ return view.getInt32(position, true) / 2_147_483_648;
224
+ }
225
+ };
226
+ return {
227
+ sampleRate: format.sampleRate,
228
+ channels: format.channels,
229
+ sampleCount,
230
+ encoding,
231
+ sample,
232
+ };
233
+ }