salgadev commited on
Commit
dcc72ff
·
verified ·
1 Parent(s): 83b1ddb

Sync from GitHub 46f60997

Browse files
apps/web/__tests__/page.test.tsx ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { render, screen, cleanup, fireEvent } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+
5
+ // The page imports Web Audio + MIDI recorder + canvas visualizers that don't
6
+ // exist cleanly under happy-dom. Mock the noisy, browser-only modules so we
7
+ // can exercise the component's UI logic.
8
+ vi.mock("@/lib/jambuddy/player", async () => {
9
+ const actual =
10
+ await vi.importActual<typeof import("@/lib/jambuddy/player")>(
11
+ "@/lib/jambuddy/player",
12
+ );
13
+ return {
14
+ ...actual,
15
+ playTogether: vi.fn(async () => ({
16
+ stop: vi.fn(),
17
+ done: new Promise<void>((r) => setTimeout(r, 5000)),
18
+ })),
19
+ playAudioTogether: vi.fn(async () => ({
20
+ stop: vi.fn(),
21
+ done: new Promise<void>((r) => setTimeout(r, 5000)),
22
+ })),
23
+ };
24
+ });
25
+
26
+ vi.mock("@/lib/jambuddy/recorder", () => ({
27
+ createMidiRecorder: vi.fn(async () => ({
28
+ start: vi.fn(),
29
+ stop: vi.fn(() => ({ notes: [], durationSec: 0 })),
30
+ isActive: vi.fn(() => false),
31
+ dispose: vi.fn(),
32
+ })),
33
+ recordedNotesAsFile: vi.fn(() => new File([""], "cap.mid")),
34
+ }));
35
+
36
+ vi.mock("@/lib/jambuddy/visualizer", () => ({
37
+ Visualizer: () => <div data-testid="visualizer" />,
38
+ }));
39
+
40
+ import HomePage from "@/app/page";
41
+
42
+ // Object URLs the component creates (take audio + response audio).
43
+ const createObjectURL = vi.fn(() => "blob:fake-audio");
44
+ vi.stubGlobal("URL.createObjectURL", createObjectURL);
45
+
46
+ /** Drive the app to a state with an audio take + a buddy response. */
47
+ async function setUpTakeAndResponse(user: ReturnType<typeof userEvent.setup>) {
48
+ render(<HomePage />);
49
+ // Mock the /api/jambuddy/detect + /api/jambuddy calls (fetch).
50
+ vi.stubGlobal(
51
+ "fetch",
52
+ vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
53
+ const u = String(url);
54
+ if (u.includes("/detect")) {
55
+ return new Response(
56
+ JSON.stringify({ bpm: 158, duration: 24 }),
57
+ { status: 200, headers: { "Content-Type": "application/json" } },
58
+ );
59
+ }
60
+ // joinIn -> return the generated audio blob.
61
+ return new Response(
62
+ new Blob([new Uint8Array([1, 2, 3])], { type: "audio/wav" }),
63
+ {
64
+ status: 200,
65
+ headers: {
66
+ "X-Jam-Buddy-BPM": "158",
67
+ "X-Jam-Buddy-Time": "1000",
68
+ "Content-Type": "audio/wav",
69
+ },
70
+ },
71
+ );
72
+ }),
73
+ );
74
+
75
+ // Upload an audio take so playBoth takes the audio-mix path.
76
+ const file = new File([new Uint8Array([1, 2, 3, 4])], "take.wav", {
77
+ type: "audio/wav",
78
+ });
79
+ const input = document.querySelector<HTMLInputElement>(
80
+ 'input[type="file"]',
81
+ );
82
+ if (!input) throw new Error("file input not found");
83
+ fireEvent.change(input, { target: { files: [file] } });
84
+
85
+ // Generate the response.
86
+ await user.click(screen.getByRole("button", { name: "JOIN IN" }));
87
+ await screen.findByText(/Done — your buddy produced/i);
88
+ }
89
+
90
+ describe("PLAY TOGETHER toggle", () => {
91
+ beforeEach(() => {
92
+ vi.clearAllMocks();
93
+ vi.unstubAllGlobals();
94
+ vi.stubGlobal("URL.createObjectURL", createObjectURL);
95
+ });
96
+
97
+ afterEach(() => {
98
+ cleanup();
99
+ });
100
+
101
+ it("is disabled when there is no generated response", () => {
102
+ render(<HomePage />);
103
+ const btn = screen.getByRole("button", { name: "PLAY TOGETHER" });
104
+ expect(btn).toBeDisabled();
105
+ });
106
+
107
+ it("stays enabled while playing and toggles to STOP, then back", async () => {
108
+ const user = userEvent.setup();
109
+ await setUpTakeAndResponse(user);
110
+
111
+ const btn = screen.getByRole("button", { name: "PLAY TOGETHER" });
112
+ expect(btn).toBeEnabled();
113
+
114
+ // Start playback.
115
+ await user.click(btn);
116
+ const stopBtn = screen.getByRole("button", { name: "■ STOP" });
117
+ // REGRESSION GUARD: must remain enabled so it can be clicked to stop.
118
+ expect(stopBtn).toBeEnabled();
119
+
120
+ // Click again to stop.
121
+ await user.click(stopBtn);
122
+ expect(
123
+ screen.getByRole("button", { name: "PLAY TOGETHER" }),
124
+ ).toBeEnabled();
125
+ });
126
+ });
apps/web/__tests__/setup.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ import "@testing-library/jest-dom/vitest";
apps/web/app/globals.css CHANGED
@@ -121,6 +121,75 @@
121
  cursor: wait;
122
  }
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  /* Big RED record button — a physical record arm with a glowing tip. */
125
  .jambuddy-record {
126
  border-radius: 0.5rem;
 
121
  cursor: wait;
122
  }
123
 
124
+ /* Sampler/sequencer transport pad — a big backlit pad with an LED color
125
+ * (set via --pad-c) that brightens when pressed. */
126
+ .jambuddy-padbig {
127
+ border-radius: 0.6rem;
128
+ border: 2px solid rgba(0, 0, 0, 0.6);
129
+ background:
130
+ radial-gradient(circle at 50% 30%, rgba(255, 255, 255, 0.06), transparent 60%),
131
+ linear-gradient(180deg, #2a2d3d 0%, #1a1c28 100%);
132
+ color: var(--pad-c, #f4a261);
133
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
134
+ font-size: 1.1rem;
135
+ font-weight: 800;
136
+ letter-spacing: 0.12em;
137
+ padding: 1.1rem 0;
138
+ cursor: pointer;
139
+ box-shadow:
140
+ inset 0 -4px 8px rgba(0, 0, 0, 0.6),
141
+ inset 0 1px 0 rgba(255, 255, 255, 0.15),
142
+ 0 3px 0 rgba(0, 0, 0, 0.5);
143
+ transition: transform 0.08s, box-shadow 0.08s, filter 0.15s;
144
+ }
145
+ .jambuddy-padbig:hover {
146
+ filter: brightness(1.12);
147
+ }
148
+ .jambuddy-padbig[aria-pressed="true"],
149
+ .jambuddy-padbig:active {
150
+ transform: translateY(2px);
151
+ box-shadow:
152
+ inset 0 2px 6px rgba(0, 0, 0, 0.6),
153
+ 0 0 16px var(--pad-c, #f4a261),
154
+ 0 1px 0 rgba(0, 0, 0, 0.5);
155
+ }
156
+ .jambuddy-padbig:disabled {
157
+ opacity: 0.4;
158
+ cursor: wait;
159
+ }
160
+ .jambuddy-padbig__label {
161
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
162
+ }
163
+
164
+ /* Icon download button pinned beside a waveform — sized to match the track,
165
+ * vertically centered, with a visible border and hover glow. */
166
+ .jambuddy-save {
167
+ display: inline-flex;
168
+ align-items: center;
169
+ justify-content: center;
170
+ align-self: center;
171
+ width: 3rem;
172
+ height: 3rem;
173
+ border-radius: 0.5rem;
174
+ border: 1px solid #2a2d3d;
175
+ background: linear-gradient(180deg, #2a2d3d 0%, #1a1c28 100%);
176
+ color: #5fd38a;
177
+ cursor: pointer;
178
+ transition: filter 0.15s, box-shadow 0.15s, transform 0.08s;
179
+ }
180
+ .jambuddy-save:hover {
181
+ filter: brightness(1.2);
182
+ box-shadow: 0 0 10px rgba(95, 211, 138, 0.3);
183
+ }
184
+ .jambuddy-save:active {
185
+ transform: translateY(1px);
186
+ }
187
+ .jambuddy-save:disabled {
188
+ opacity: 0.4;
189
+ cursor: default;
190
+ box-shadow: none;
191
+ }
192
+
193
  /* Big RED record button — a physical record arm with a glowing tip. */
194
  .jambuddy-record {
195
  border-radius: 0.5rem;
apps/web/app/page.tsx CHANGED
@@ -16,7 +16,7 @@ import {
16
  type BuddyMood,
17
  type InputInstrument,
18
  } from "@/lib/jambuddy/prompt";
19
- import { parseMidi, isPercussion, midiDuration } from "@/lib/jambuddy/player";
20
  import { createMidiRecorder, recordedNotesAsFile, type MidiRecorder } from "@/lib/jambuddy/recorder";
21
  import { Visualizer } from "@/lib/jambuddy/visualizer";
22
 
@@ -134,8 +134,11 @@ export default function HomePage() {
134
  const [midiFile, setMidiFile] = useState<File | null>(null);
135
  const [midiBytes, setMidiBytes] = useState<ArrayBuffer | null>(null);
136
  const [audioFile, setAudioFile] = useState<File | null>(null);
 
 
137
  const [status, setStatus] = useState<string>("Ready.");
138
  const [audioUrl, setAudioUrl] = useState<string | null>(null);
 
139
  const [usedBpm, setUsedBpm] = useState<number | null>(null);
140
  const [usedSeconds, setUsedSeconds] = useState<number | null>(null);
141
  const [busy, setBusy] = useState(false);
@@ -191,6 +194,8 @@ export default function HomePage() {
191
  setMidiFile(null);
192
  setMidiBytes(null);
193
  setAudioFile(null);
 
 
194
  if (!f) {
195
  setStatus("Ready.");
196
  return;
@@ -215,6 +220,7 @@ export default function HomePage() {
215
  );
216
  } else {
217
  setAudioFile(f);
 
218
  // Audio is heard by the buddy (audio-to-audio). Clear the MIDI-driven
219
  // input-instrument default so the user's declaration reflects the audio.
220
  setInputInstrument("other");
@@ -310,6 +316,79 @@ export default function HomePage() {
310
  setStatus("Saved your recorded MIDI take.");
311
  }
312
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  async function joinIn() {
314
  setBusy(true);
315
  setStatus(
@@ -437,6 +516,13 @@ export default function HomePage() {
437
  />
438
  </section>
439
 
 
 
 
 
 
 
 
440
  {/* Your-take-is knob — declares what instrument the user is playing
441
  so the buddy can complement it (no MIR on input). */}
442
  <section
@@ -522,44 +608,44 @@ export default function HomePage() {
522
  : "Local CPU · free · supports negative prompt · slower"}
523
  </span>
524
  </div>
525
- <div className="mb-2 flex items-center gap-4">
 
526
  <button
527
  type="button"
528
  onClick={toggleRecord}
529
  disabled={busy}
530
  aria-pressed={recording}
531
  aria-label={recording ? "Stop recording" : "Record from MIDI controller"}
532
- className="jambuddy-record flex-1"
 
533
  >
534
- {recording ? "■ STOP" : "● RECORD"}
 
 
535
  </button>
536
  <button
537
  type="button"
538
- onClick={saveRecordedMidi}
539
- disabled={!recordedMidiUrl || busy}
540
- className="jambuddy-trigger flex-1"
541
- style={{
542
- background: "linear-gradient(180deg,#5fd38a 0%,#3aa55f 100%)",
543
- boxShadow: "0 2px 0 #256b3f",
544
- }}
545
  >
546
- SAVE MIDI
 
 
547
  </button>
548
  <button
549
  type="button"
550
- onClick={joinIn}
551
- disabled={busy}
552
- className="jambuddy-trigger flex-1"
 
 
553
  >
554
- {busy ? "PRODUCING…" : "JOIN IN"}
 
 
555
  </button>
556
- <div className="font-mono text-xs text-[#7f829c]">
557
- <div className="uppercase tracking-widest">Prompt</div>
558
- <div className="mt-1 max-w-[16rem] truncate text-[#e8e8f0]">
559
- {prompt}
560
- </div>
561
- <div className="mt-1 opacity-60">neg: {negativePrompt}</div>
562
- </div>
563
  </div>
564
 
565
  {/* Status + playback */}
@@ -588,26 +674,85 @@ export default function HomePage() {
588
  </p>
589
  )}
590
  {audioUrl && (
591
- <audio controls src={audioUrl} className="mt-3 w-full">
 
 
 
 
 
592
  Your browser does not support audio playback.
593
  </audio>
594
  )}
595
  </section>
596
 
597
- {/* Take + response visualizers */}
598
- {(midiBytes || audioUrl) && (
 
599
  <section
600
  aria-label="Take and response"
601
- className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2"
602
  >
603
- <Visualizer
604
- midiBytes={midiBytes}
605
- label="Your take (MIDI piano-roll)"
606
- />
607
- <Visualizer
608
- audioUrl={audioUrl}
609
- label="Buddy response (waveform)"
610
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
611
  </section>
612
  )}
613
  </div>
 
16
  type BuddyMood,
17
  type InputInstrument,
18
  } from "@/lib/jambuddy/prompt";
19
+ import { parseMidi, isPercussion, midiDuration, playTogether, playAudioTogether } from "@/lib/jambuddy/player";
20
  import { createMidiRecorder, recordedNotesAsFile, type MidiRecorder } from "@/lib/jambuddy/recorder";
21
  import { Visualizer } from "@/lib/jambuddy/visualizer";
22
 
 
134
  const [midiFile, setMidiFile] = useState<File | null>(null);
135
  const [midiBytes, setMidiBytes] = useState<ArrayBuffer | null>(null);
136
  const [audioFile, setAudioFile] = useState<File | null>(null);
137
+ // Object URL of the uploaded audio take (for playback + waveform).
138
+ const [takeAudioUrl, setTakeAudioUrl] = useState<string | null>(null);
139
  const [status, setStatus] = useState<string>("Ready.");
140
  const [audioUrl, setAudioUrl] = useState<string | null>(null);
141
+ const [isPlayingTogether, setIsPlayingTogether] = useState(false);
142
  const [usedBpm, setUsedBpm] = useState<number | null>(null);
143
  const [usedSeconds, setUsedSeconds] = useState<number | null>(null);
144
  const [busy, setBusy] = useState(false);
 
194
  setMidiFile(null);
195
  setMidiBytes(null);
196
  setAudioFile(null);
197
+ if (takeAudioUrl) URL.revokeObjectURL(takeAudioUrl);
198
+ setTakeAudioUrl(null);
199
  if (!f) {
200
  setStatus("Ready.");
201
  return;
 
220
  );
221
  } else {
222
  setAudioFile(f);
223
+ setTakeAudioUrl(URL.createObjectURL(f));
224
  // Audio is heard by the buddy (audio-to-audio). Clear the MIDI-driven
225
  // input-instrument default so the user's declaration reflects the audio.
226
  setInputInstrument("other");
 
316
  setStatus("Saved your recorded MIDI take.");
317
  }
318
 
319
+ /** Download the generated buddy response (audio) as a file. */
320
+ function saveGeneratedAudio() {
321
+ if (!audioUrl) {
322
+ setStatus("Generate a response first, then save it.");
323
+ return;
324
+ }
325
+ const a = document.createElement("a");
326
+ a.href = audioUrl;
327
+ a.download = `jambuddy-response-${new Date()
328
+ .toISOString()
329
+ .replace(/[-:]/g, "")
330
+ .replace(/\.\d+Z$/, "Z")}.${mode === "api" ? "mp3" : "wav"}`;
331
+ a.rel = "noopener";
332
+ document.body.appendChild(a);
333
+ a.click();
334
+ document.body.removeChild(a);
335
+ setStatus("Saved the buddy's response.");
336
+ }
337
+
338
+ /** Play the take and the buddy response together (audio mix, or MIDI synth).
339
+ * Toggle: pressing again stops playback.
340
+ *
341
+ * Re-entry guard: isPlayingTogether is React state (async), so a fast second
342
+ * click can read a stale `false` and start a SECOND simultaneous layer. Keep
343
+ * a synchronous ref so the toggle is atomic. */
344
+ const playbackRef = useRef<{ stop: () => void } | null>(null);
345
+ const audioRef = useRef<HTMLAudioElement | null>(null);
346
+ async function playBoth() {
347
+ if (playbackRef.current) {
348
+ // Stop: kill current playback (synchronous — immune to stale state).
349
+ playbackRef.current.stop();
350
+ playbackRef.current = null;
351
+ setIsPlayingTogether(false);
352
+ setStatus("Stopped.");
353
+ return;
354
+ }
355
+ if (!audioUrl) {
356
+ setStatus("Generate a response first.");
357
+ return;
358
+ }
359
+ // Pause the standalone audio element so the response isn't heard twice.
360
+ audioRef.current?.pause();
361
+ playbackRef.current = { stop: () => {} }; // claim the toggle synchronously
362
+ setIsPlayingTogether(true);
363
+ setStatus("Playing your take + the buddy together…");
364
+ try {
365
+ let handle: { stop: () => void; done: Promise<void> };
366
+ if (midiBytes) {
367
+ // MIDI take: render with the built-in synth, layered with the buddy.
368
+ handle = await playTogether(midiBytes, audioUrl);
369
+ } else if (takeAudioUrl) {
370
+ // Audio take: mix the two audio files on the same clock.
371
+ handle = await playAudioTogether(takeAudioUrl, audioUrl);
372
+ } else {
373
+ playbackRef.current = null;
374
+ setIsPlayingTogether(false);
375
+ setStatus("Load a take first to play it with the response.");
376
+ return;
377
+ }
378
+ playbackRef.current = handle;
379
+ await handle.done;
380
+ if (playbackRef.current === handle) {
381
+ playbackRef.current = null;
382
+ setIsPlayingTogether(false);
383
+ setStatus("Done — both played together.");
384
+ }
385
+ } catch (e) {
386
+ playbackRef.current = null;
387
+ setIsPlayingTogether(false);
388
+ setStatus(`Playback error: ${String(e)}`);
389
+ }
390
+ }
391
+
392
  async function joinIn() {
393
  setBusy(true);
394
  setStatus(
 
516
  />
517
  </section>
518
 
519
+ {/* Prompt — mirrors the knobs, shown right below them */}
520
+ <div className="mb-6 rounded bg-[#15161f] px-4 py-2 font-mono text-xs text-[#7f829c]">
521
+ <div className="uppercase tracking-widest">Prompt</div>
522
+ <div className="mt-1 truncate text-[#e8e8f0]">{prompt}</div>
523
+ <div className="mt-1 opacity-60">neg: {negativePrompt}</div>
524
+ </div>
525
+
526
  {/* Your-take-is knob — declares what instrument the user is playing
527
  so the buddy can complement it (no MIR on input). */}
528
  <section
 
608
  : "Local CPU · free · supports negative prompt · slower"}
609
  </span>
610
  </div>
611
+ {/* Transport sampler/sequencer pads */}
612
+ <div className="mb-2 grid grid-cols-3 gap-4">
613
  <button
614
  type="button"
615
  onClick={toggleRecord}
616
  disabled={busy}
617
  aria-pressed={recording}
618
  aria-label={recording ? "Stop recording" : "Record from MIDI controller"}
619
+ className="jambuddy-padbig flex-1"
620
+ style={{ ["--pad-c" as string]: "#e05252" }}
621
  >
622
+ <span className="jambuddy-padbig__label">
623
+ {recording ? "■ STOP" : "● RECORD"}
624
+ </span>
625
  </button>
626
  <button
627
  type="button"
628
+ onClick={joinIn}
629
+ disabled={busy}
630
+ className="jambuddy-padbig flex-1"
631
+ style={{ ["--pad-c" as string]: "#f4a261" }}
 
 
 
632
  >
633
+ <span className="jambuddy-padbig__label">
634
+ {busy ? "PRODUCING…" : "JOIN IN"}
635
+ </span>
636
  </button>
637
  <button
638
  type="button"
639
+ onClick={playBoth}
640
+ disabled={!audioUrl}
641
+ aria-pressed={isPlayingTogether}
642
+ className="jambuddy-padbig flex-1"
643
+ style={{ ["--pad-c" as string]: "#5fd38a" }}
644
  >
645
+ <span className="jambuddy-padbig__label">
646
+ {isPlayingTogether ? "■ STOP" : "PLAY TOGETHER"}
647
+ </span>
648
  </button>
 
 
 
 
 
 
 
649
  </div>
650
 
651
  {/* Status + playback */}
 
674
  </p>
675
  )}
676
  {audioUrl && (
677
+ <audio
678
+ ref={audioRef}
679
+ controls
680
+ src={audioUrl}
681
+ className="mt-3 w-full"
682
+ >
683
  Your browser does not support audio playback.
684
  </audio>
685
  )}
686
  </section>
687
 
688
+ {/* Take + response visualizers — stacked vertically, DAW-style.
689
+ Each waveform has its own SAVE button beside it. */}
690
+ {(midiBytes || takeAudioUrl || audioUrl) && (
691
  <section
692
  aria-label="Take and response"
693
+ className="mt-4 flex flex-col gap-4"
694
  >
695
+ {midiBytes ? (
696
+ <div className="flex items-end gap-3">
697
+ <div className="flex-1">
698
+ <Visualizer
699
+ midiBytes={midiBytes}
700
+ label="Your take (MIDI piano-roll)"
701
+ />
702
+ </div>
703
+ <button
704
+ type="button"
705
+ onClick={saveRecordedMidi}
706
+ disabled={!recordedMidiUrl}
707
+ className="jambuddy-save"
708
+ title="Download this MIDI take"
709
+ aria-label="Download this MIDI take"
710
+ >
711
+ <svg viewBox="0 0 24 24" width="26" height="26" fill="none"
712
+ stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"
713
+ strokeLinejoin="round" aria-hidden="true">
714
+ <path d="M12 3v12" />
715
+ <path d="M6 11l6 6 6-6" />
716
+ <path d="M4 20h16" />
717
+ </svg>
718
+ </button>
719
+ </div>
720
+ ) : takeAudioUrl ? (
721
+ <div className="flex items-end gap-3">
722
+ <div className="flex-1">
723
+ <Visualizer
724
+ audioUrl={takeAudioUrl}
725
+ label="Your take (audio waveform)"
726
+ />
727
+ </div>
728
+ </div>
729
+ ) : null}
730
+ {audioUrl && (
731
+ <div className="flex items-end gap-3">
732
+ <div className="flex-1">
733
+ <Visualizer
734
+ audioUrl={audioUrl}
735
+ label="Buddy response (waveform)"
736
+ />
737
+ </div>
738
+ <button
739
+ type="button"
740
+ onClick={saveGeneratedAudio}
741
+ disabled={!audioUrl}
742
+ className="jambuddy-save"
743
+ title="Download this response"
744
+ aria-label="Download this response"
745
+ >
746
+ <svg viewBox="0 0 24 24" width="26" height="26" fill="none"
747
+ stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"
748
+ strokeLinejoin="round" aria-hidden="true">
749
+ <path d="M12 3v12" />
750
+ <path d="M6 11l6 6 6-6" />
751
+ <path d="M4 20h16" />
752
+ </svg>
753
+ </button>
754
+ </div>
755
+ )}
756
  </section>
757
  )}
758
  </div>
apps/web/lib/jambuddy/player.ts CHANGED
@@ -207,3 +207,73 @@ export function midiDuration(buf: ArrayBuffer): number {
207
  return 30;
208
  }
209
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  return 30;
208
  }
209
  }
210
+
211
+ /**
212
+ * Play an AUDIO take and the buddy response TOGETHER (mixed on the same clock).
213
+ * This is the audio-take analogue of playTogether (which is MIDI + buddy).
214
+ * Both are decoded AudioBuffers scheduled to start at the same time.
215
+ *
216
+ * @param takeAudioUrl the user's audio take (object URL)
217
+ * @param buddyAudioUrl the generated response (object URL)
218
+ * @returns an object with stop() and a promise resolving when both finish.
219
+ */
220
+ export async function playAudioTogether(
221
+ takeAudioUrl: string,
222
+ buddyAudioUrl: string,
223
+ ): Promise<{ stop: () => void; done: Promise<void> }> {
224
+ const ctx = new AudioContext();
225
+ await ctx.resume();
226
+
227
+ const decode = async (url: string) => {
228
+ const resp = await fetch(url);
229
+ const buf = await resp.arrayBuffer();
230
+ return ctx.decodeAudioData(buf);
231
+ };
232
+
233
+ const [takeBuffer, buddyBuffer] = await Promise.all([
234
+ decode(takeAudioUrl),
235
+ decode(buddyAudioUrl),
236
+ ]);
237
+
238
+ const master = ctx.createGain();
239
+ master.gain.value = 0.8;
240
+ master.connect(ctx.destination);
241
+
242
+ const startAt = ctx.currentTime + 0.1;
243
+
244
+ const takeSrc = ctx.createBufferSource();
245
+ takeSrc.buffer = takeBuffer;
246
+ takeSrc.connect(master);
247
+ takeSrc.start(startAt);
248
+
249
+ const buddySrc = ctx.createBufferSource();
250
+ buddySrc.buffer = buddyBuffer;
251
+ buddySrc.connect(master);
252
+ buddySrc.start(startAt);
253
+
254
+ const end = startAt + Math.max(takeBuffer.duration, buddyBuffer.duration);
255
+
256
+ const done = new Promise<void>((resolve) => {
257
+ setTimeout(() => {
258
+ try {
259
+ ctx.close();
260
+ } catch {
261
+ /* already closed */
262
+ }
263
+ resolve();
264
+ }, Math.max(0, (end - ctx.currentTime) * 1000) + 200);
265
+ });
266
+
267
+ return {
268
+ stop: () => {
269
+ try {
270
+ takeSrc.stop();
271
+ buddySrc.stop();
272
+ ctx.close();
273
+ } catch {
274
+ /* ignore */
275
+ }
276
+ },
277
+ done,
278
+ };
279
+ }
apps/web/package.json CHANGED
@@ -20,6 +20,10 @@
20
  "react-dom": "^18.3.1"
21
  },
22
  "devDependencies": {
 
 
 
 
23
  "@types/node": "^22.9.3",
24
  "@types/react": "^18.3.12",
25
  "@types/react-dom": "^18.3.1",
 
20
  "react-dom": "^18.3.1"
21
  },
22
  "devDependencies": {
23
+ "@testing-library/dom": "^10.4.1",
24
+ "@testing-library/jest-dom": "^7.0.1",
25
+ "@testing-library/react": "^16.3.2",
26
+ "@testing-library/user-event": "^14.6.6",
27
  "@types/node": "^22.9.3",
28
  "@types/react": "^18.3.12",
29
  "@types/react-dom": "^18.3.1",
apps/web/vitest.config.ts CHANGED
@@ -7,6 +7,7 @@ export default defineConfig({
7
  test: {
8
  environment: "happy-dom",
9
  globals: false,
 
10
  include: ["__tests__/**/*.test.ts", "__tests__/**/*.test.tsx"],
11
  },
12
  resolve: {
 
7
  test: {
8
  environment: "happy-dom",
9
  globals: false,
10
+ setupFiles: ["./__tests__/setup.ts"],
11
  include: ["__tests__/**/*.test.ts", "__tests__/**/*.test.tsx"],
12
  },
13
  resolve: {
pnpm-lock.yaml CHANGED
@@ -30,6 +30,18 @@ importers:
30
  specifier: ^18.3.1
31
  version: 18.3.1(react@18.3.1)
32
  devDependencies:
 
 
 
 
 
 
 
 
 
 
 
 
33
  '@types/node':
34
  specifier: ^22.9.3
35
  version: 22.20.1
@@ -75,6 +87,9 @@ importers:
75
 
76
  packages:
77
 
 
 
 
78
  '@alloc/quick-lru@5.2.0':
79
  resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
80
  engines: {node: '>=10'}
@@ -150,6 +165,10 @@ packages:
150
  peerDependencies:
151
  '@babel/core': ^7.0.0-0
152
 
 
 
 
 
153
  '@babel/template@7.29.7':
154
  resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
155
  engines: {node: '>=6.9.0'}
@@ -593,12 +612,50 @@ packages:
593
  '@swc/helpers@0.5.5':
594
  resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
595
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
596
  '@tonejs/midi@2.0.28':
597
  resolution: {integrity: sha512-RII6YpInPsOZ5t3Si/20QKpNqB1lZ2OCFJSOzJxz38YdY/3zqDr3uaml4JuCWkdixuPqP1/TBnXzhQ39csyoVg==}
598
 
599
  '@tybys/wasm-util@0.10.3':
600
  resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
601
 
 
 
 
602
  '@types/babel__core@7.20.5':
603
  resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
604
 
@@ -863,6 +920,10 @@ packages:
863
  resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
864
  engines: {node: '>=8'}
865
 
 
 
 
 
866
  ansi-styles@6.2.3:
867
  resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
868
  engines: {node: '>=12'}
@@ -880,6 +941,9 @@ packages:
880
  argparse@2.0.1:
881
  resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
882
 
 
 
 
883
  aria-query@5.3.2:
884
  resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
885
  engines: {node: '>= 0.4'}
@@ -1055,6 +1119,9 @@ packages:
1055
  resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
1056
  engines: {node: '>= 8'}
1057
 
 
 
 
1058
  cssesc@3.0.0:
1059
  resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
1060
  engines: {node: '>=4'}
@@ -1110,6 +1177,10 @@ packages:
1110
  resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
1111
  engines: {node: '>= 0.4'}
1112
 
 
 
 
 
1113
  didyoumean@1.2.2:
1114
  resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
1115
 
@@ -1124,6 +1195,12 @@ packages:
1124
  resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
1125
  engines: {node: '>=6.0.0'}
1126
 
 
 
 
 
 
 
1127
  dunder-proto@1.0.1:
1128
  resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
1129
  engines: {node: '>= 0.4'}
@@ -1494,6 +1571,10 @@ packages:
1494
  resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
1495
  engines: {node: '>=0.8.19'}
1496
 
 
 
 
 
1497
  inflight@1.0.6:
1498
  resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
1499
  deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
@@ -1717,6 +1798,10 @@ packages:
1717
  lru-cache@5.1.1:
1718
  resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
1719
 
 
 
 
 
1720
  magic-string@0.30.21:
1721
  resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
1722
 
@@ -1735,6 +1820,10 @@ packages:
1735
  midi-file@1.2.4:
1736
  resolution: {integrity: sha512-B5SnBC6i2bwJIXTY9MElIydJwAmnKx+r5eJ1jknTLetzLflEl0GWveuBB6ACrQpecSRkOB6fhTx1PwXk2BVxnA==}
1737
 
 
 
 
 
1738
  minimatch@10.2.6:
1739
  resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
1740
  engines: {node: 18 || 20 || >=22}
@@ -1965,6 +2054,10 @@ packages:
1965
  resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
1966
  engines: {node: '>= 0.8.0'}
1967
 
 
 
 
 
1968
  prop-types@15.8.1:
1969
  resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
1970
 
@@ -1983,6 +2076,9 @@ packages:
1983
  react-is@16.13.1:
1984
  resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
1985
 
 
 
 
1986
  react-refresh@0.17.0:
1987
  resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
1988
  engines: {node: '>=0.10.0'}
@@ -1998,6 +2094,10 @@ packages:
1998
  resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
1999
  engines: {node: '>=8.10.0'}
2000
 
 
 
 
 
2001
  reflect.getprototypeof@1.0.10:
2002
  resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
2003
  engines: {node: '>= 0.4'}
@@ -2171,6 +2271,10 @@ packages:
2171
  resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
2172
  engines: {node: '>=4'}
2173
 
 
 
 
 
2174
  strip-json-comments@3.1.1:
2175
  resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
2176
  engines: {node: '>=8'}
@@ -2427,6 +2531,8 @@ packages:
2427
 
2428
  snapshots:
2429
 
 
 
2430
  '@alloc/quick-lru@5.2.0': {}
2431
 
2432
  '@babel/code-frame@7.29.7':
@@ -2518,6 +2624,8 @@ snapshots:
2518
  '@babel/core': 7.29.7
2519
  '@babel/helper-plugin-utils': 7.29.7
2520
 
 
 
2521
  '@babel/template@7.29.7':
2522
  dependencies:
2523
  '@babel/code-frame': 7.29.7
@@ -2837,6 +2945,43 @@ snapshots:
2837
  '@swc/counter': 0.1.3
2838
  tslib: 2.8.1
2839
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2840
  '@tonejs/midi@2.0.28':
2841
  dependencies:
2842
  array-flatten: 3.0.0
@@ -2847,6 +2992,8 @@ snapshots:
2847
  tslib: 2.8.1
2848
  optional: true
2849
 
 
 
2850
  '@types/babel__core@7.20.5':
2851
  dependencies:
2852
  '@babel/parser': 7.29.8
@@ -3123,6 +3270,8 @@ snapshots:
3123
  dependencies:
3124
  color-convert: 2.0.1
3125
 
 
 
3126
  ansi-styles@6.2.3: {}
3127
 
3128
  any-promise@1.3.0: {}
@@ -3136,6 +3285,10 @@ snapshots:
3136
 
3137
  argparse@2.0.1: {}
3138
 
 
 
 
 
3139
  aria-query@5.3.2: {}
3140
 
3141
  array-buffer-byte-length@1.0.2:
@@ -3339,6 +3492,8 @@ snapshots:
3339
  shebang-command: 2.0.0
3340
  which: 2.0.2
3341
 
 
 
3342
  cssesc@3.0.0: {}
3343
 
3344
  csstype@3.2.3: {}
@@ -3387,6 +3542,8 @@ snapshots:
3387
  has-property-descriptors: 1.0.2
3388
  object-keys: 1.1.1
3389
 
 
 
3390
  didyoumean@1.2.2: {}
3391
 
3392
  dlv@1.1.3: {}
@@ -3399,6 +3556,10 @@ snapshots:
3399
  dependencies:
3400
  esutils: 2.0.3
3401
 
 
 
 
 
3402
  dunder-proto@1.0.1:
3403
  dependencies:
3404
  call-bind-apply-helpers: 1.0.2
@@ -3948,6 +4109,8 @@ snapshots:
3948
 
3949
  imurmurhash@0.1.4: {}
3950
 
 
 
3951
  inflight@1.0.6:
3952
  dependencies:
3953
  once: 1.4.0
@@ -4170,6 +4333,8 @@ snapshots:
4170
  dependencies:
4171
  yallist: 3.1.1
4172
 
 
 
4173
  magic-string@0.30.21:
4174
  dependencies:
4175
  '@jridgewell/sourcemap-codec': 1.5.5
@@ -4185,6 +4350,8 @@ snapshots:
4185
 
4186
  midi-file@1.2.4: {}
4187
 
 
 
4188
  minimatch@10.2.6:
4189
  dependencies:
4190
  brace-expansion: 5.0.9
@@ -4401,6 +4568,12 @@ snapshots:
4401
 
4402
  prelude-ls@1.2.1: {}
4403
 
 
 
 
 
 
 
4404
  prop-types@15.8.1:
4405
  dependencies:
4406
  loose-envify: 1.4.0
@@ -4419,6 +4592,8 @@ snapshots:
4419
 
4420
  react-is@16.13.1: {}
4421
 
 
 
4422
  react-refresh@0.17.0: {}
4423
 
4424
  react@18.3.1:
@@ -4433,6 +4608,11 @@ snapshots:
4433
  dependencies:
4434
  picomatch: 2.3.2
4435
 
 
 
 
 
 
4436
  reflect.getprototypeof@1.0.10:
4437
  dependencies:
4438
  call-bind: 1.0.9
@@ -4690,6 +4870,10 @@ snapshots:
4690
 
4691
  strip-bom@3.0.0: {}
4692
 
 
 
 
 
4693
  strip-json-comments@3.1.1: {}
4694
 
4695
  styled-jsx@5.1.1(@babel/core@7.29.7)(react@18.3.1):
 
30
  specifier: ^18.3.1
31
  version: 18.3.1(react@18.3.1)
32
  devDependencies:
33
+ '@testing-library/dom':
34
+ specifier: ^10.4.1
35
+ version: 10.4.1
36
+ '@testing-library/jest-dom':
37
+ specifier: ^7.0.1
38
+ version: 7.0.1(@testing-library/dom@10.4.1)(vitest@2.1.9(@types/node@22.20.1)(happy-dom@15.11.7))
39
+ '@testing-library/react':
40
+ specifier: ^16.3.2
41
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
42
+ '@testing-library/user-event':
43
+ specifier: ^14.6.6
44
+ version: 14.6.6(@testing-library/dom@10.4.1)
45
  '@types/node':
46
  specifier: ^22.9.3
47
  version: 22.20.1
 
87
 
88
  packages:
89
 
90
+ '@adobe/css-tools@4.5.0':
91
+ resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
92
+
93
  '@alloc/quick-lru@5.2.0':
94
  resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
95
  engines: {node: '>=10'}
 
165
  peerDependencies:
166
  '@babel/core': ^7.0.0-0
167
 
168
+ '@babel/runtime@7.29.7':
169
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
170
+ engines: {node: '>=6.9.0'}
171
+
172
  '@babel/template@7.29.7':
173
  resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
174
  engines: {node: '>=6.9.0'}
 
612
  '@swc/helpers@0.5.5':
613
  resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
614
 
615
+ '@testing-library/dom@10.4.1':
616
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
617
+ engines: {node: '>=18'}
618
+
619
+ '@testing-library/jest-dom@7.0.1':
620
+ resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==}
621
+ engines: {node: '>=22', npm: '>=6', yarn: '>=1'}
622
+ peerDependencies:
623
+ '@testing-library/dom': '>=10 <11'
624
+ vitest: '>= 0.32'
625
+ peerDependenciesMeta:
626
+ vitest:
627
+ optional: true
628
+
629
+ '@testing-library/react@16.3.2':
630
+ resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
631
+ engines: {node: '>=18'}
632
+ peerDependencies:
633
+ '@testing-library/dom': ^10.0.0
634
+ '@types/react': ^18.0.0 || ^19.0.0
635
+ '@types/react-dom': ^18.0.0 || ^19.0.0
636
+ react: ^18.0.0 || ^19.0.0
637
+ react-dom: ^18.0.0 || ^19.0.0
638
+ peerDependenciesMeta:
639
+ '@types/react':
640
+ optional: true
641
+ '@types/react-dom':
642
+ optional: true
643
+
644
+ '@testing-library/user-event@14.6.6':
645
+ resolution: {integrity: sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==}
646
+ engines: {node: '>=12', npm: '>=6'}
647
+ peerDependencies:
648
+ '@testing-library/dom': '>=7.21.4'
649
+
650
  '@tonejs/midi@2.0.28':
651
  resolution: {integrity: sha512-RII6YpInPsOZ5t3Si/20QKpNqB1lZ2OCFJSOzJxz38YdY/3zqDr3uaml4JuCWkdixuPqP1/TBnXzhQ39csyoVg==}
652
 
653
  '@tybys/wasm-util@0.10.3':
654
  resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
655
 
656
+ '@types/aria-query@5.0.4':
657
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
658
+
659
  '@types/babel__core@7.20.5':
660
  resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
661
 
 
920
  resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
921
  engines: {node: '>=8'}
922
 
923
+ ansi-styles@5.2.0:
924
+ resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
925
+ engines: {node: '>=10'}
926
+
927
  ansi-styles@6.2.3:
928
  resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
929
  engines: {node: '>=12'}
 
941
  argparse@2.0.1:
942
  resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
943
 
944
+ aria-query@5.3.0:
945
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
946
+
947
  aria-query@5.3.2:
948
  resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
949
  engines: {node: '>= 0.4'}
 
1119
  resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
1120
  engines: {node: '>= 8'}
1121
 
1122
+ css.escape@1.5.1:
1123
+ resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
1124
+
1125
  cssesc@3.0.0:
1126
  resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
1127
  engines: {node: '>=4'}
 
1177
  resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
1178
  engines: {node: '>= 0.4'}
1179
 
1180
+ dequal@2.0.3:
1181
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
1182
+ engines: {node: '>=6'}
1183
+
1184
  didyoumean@1.2.2:
1185
  resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
1186
 
 
1195
  resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
1196
  engines: {node: '>=6.0.0'}
1197
 
1198
+ dom-accessibility-api@0.5.16:
1199
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
1200
+
1201
+ dom-accessibility-api@0.6.3:
1202
+ resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
1203
+
1204
  dunder-proto@1.0.1:
1205
  resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
1206
  engines: {node: '>= 0.4'}
 
1571
  resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
1572
  engines: {node: '>=0.8.19'}
1573
 
1574
+ indent-string@4.0.0:
1575
+ resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
1576
+ engines: {node: '>=8'}
1577
+
1578
  inflight@1.0.6:
1579
  resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
1580
  deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
 
1798
  lru-cache@5.1.1:
1799
  resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
1800
 
1801
+ lz-string@1.5.0:
1802
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
1803
+ hasBin: true
1804
+
1805
  magic-string@0.30.21:
1806
  resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
1807
 
 
1820
  midi-file@1.2.4:
1821
  resolution: {integrity: sha512-B5SnBC6i2bwJIXTY9MElIydJwAmnKx+r5eJ1jknTLetzLflEl0GWveuBB6ACrQpecSRkOB6fhTx1PwXk2BVxnA==}
1822
 
1823
+ min-indent@1.0.1:
1824
+ resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
1825
+ engines: {node: '>=4'}
1826
+
1827
  minimatch@10.2.6:
1828
  resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
1829
  engines: {node: 18 || 20 || >=22}
 
2054
  resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
2055
  engines: {node: '>= 0.8.0'}
2056
 
2057
+ pretty-format@27.5.1:
2058
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
2059
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
2060
+
2061
  prop-types@15.8.1:
2062
  resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
2063
 
 
2076
  react-is@16.13.1:
2077
  resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
2078
 
2079
+ react-is@17.0.2:
2080
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
2081
+
2082
  react-refresh@0.17.0:
2083
  resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
2084
  engines: {node: '>=0.10.0'}
 
2094
  resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
2095
  engines: {node: '>=8.10.0'}
2096
 
2097
+ redent@3.0.0:
2098
+ resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
2099
+ engines: {node: '>=8'}
2100
+
2101
  reflect.getprototypeof@1.0.10:
2102
  resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
2103
  engines: {node: '>= 0.4'}
 
2271
  resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
2272
  engines: {node: '>=4'}
2273
 
2274
+ strip-indent@3.0.0:
2275
+ resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
2276
+ engines: {node: '>=8'}
2277
+
2278
  strip-json-comments@3.1.1:
2279
  resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
2280
  engines: {node: '>=8'}
 
2531
 
2532
  snapshots:
2533
 
2534
+ '@adobe/css-tools@4.5.0': {}
2535
+
2536
  '@alloc/quick-lru@5.2.0': {}
2537
 
2538
  '@babel/code-frame@7.29.7':
 
2624
  '@babel/core': 7.29.7
2625
  '@babel/helper-plugin-utils': 7.29.7
2626
 
2627
+ '@babel/runtime@7.29.7': {}
2628
+
2629
  '@babel/template@7.29.7':
2630
  dependencies:
2631
  '@babel/code-frame': 7.29.7
 
2945
  '@swc/counter': 0.1.3
2946
  tslib: 2.8.1
2947
 
2948
+ '@testing-library/dom@10.4.1':
2949
+ dependencies:
2950
+ '@babel/code-frame': 7.29.7
2951
+ '@babel/runtime': 7.29.7
2952
+ '@types/aria-query': 5.0.4
2953
+ aria-query: 5.3.0
2954
+ dom-accessibility-api: 0.5.16
2955
+ lz-string: 1.5.0
2956
+ picocolors: 1.1.1
2957
+ pretty-format: 27.5.1
2958
+
2959
+ '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@2.1.9(@types/node@22.20.1)(happy-dom@15.11.7))':
2960
+ dependencies:
2961
+ '@adobe/css-tools': 4.5.0
2962
+ '@testing-library/dom': 10.4.1
2963
+ aria-query: 5.3.2
2964
+ css.escape: 1.5.1
2965
+ dom-accessibility-api: 0.6.3
2966
+ picocolors: 1.1.1
2967
+ redent: 3.0.0
2968
+ optionalDependencies:
2969
+ vitest: 2.1.9(@types/node@22.20.1)(happy-dom@15.11.7)
2970
+
2971
+ '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
2972
+ dependencies:
2973
+ '@babel/runtime': 7.29.7
2974
+ '@testing-library/dom': 10.4.1
2975
+ react: 18.3.1
2976
+ react-dom: 18.3.1(react@18.3.1)
2977
+ optionalDependencies:
2978
+ '@types/react': 18.3.31
2979
+ '@types/react-dom': 18.3.7(@types/react@18.3.31)
2980
+
2981
+ '@testing-library/user-event@14.6.6(@testing-library/dom@10.4.1)':
2982
+ dependencies:
2983
+ '@testing-library/dom': 10.4.1
2984
+
2985
  '@tonejs/midi@2.0.28':
2986
  dependencies:
2987
  array-flatten: 3.0.0
 
2992
  tslib: 2.8.1
2993
  optional: true
2994
 
2995
+ '@types/aria-query@5.0.4': {}
2996
+
2997
  '@types/babel__core@7.20.5':
2998
  dependencies:
2999
  '@babel/parser': 7.29.8
 
3270
  dependencies:
3271
  color-convert: 2.0.1
3272
 
3273
+ ansi-styles@5.2.0: {}
3274
+
3275
  ansi-styles@6.2.3: {}
3276
 
3277
  any-promise@1.3.0: {}
 
3285
 
3286
  argparse@2.0.1: {}
3287
 
3288
+ aria-query@5.3.0:
3289
+ dependencies:
3290
+ dequal: 2.0.3
3291
+
3292
  aria-query@5.3.2: {}
3293
 
3294
  array-buffer-byte-length@1.0.2:
 
3492
  shebang-command: 2.0.0
3493
  which: 2.0.2
3494
 
3495
+ css.escape@1.5.1: {}
3496
+
3497
  cssesc@3.0.0: {}
3498
 
3499
  csstype@3.2.3: {}
 
3542
  has-property-descriptors: 1.0.2
3543
  object-keys: 1.1.1
3544
 
3545
+ dequal@2.0.3: {}
3546
+
3547
  didyoumean@1.2.2: {}
3548
 
3549
  dlv@1.1.3: {}
 
3556
  dependencies:
3557
  esutils: 2.0.3
3558
 
3559
+ dom-accessibility-api@0.5.16: {}
3560
+
3561
+ dom-accessibility-api@0.6.3: {}
3562
+
3563
  dunder-proto@1.0.1:
3564
  dependencies:
3565
  call-bind-apply-helpers: 1.0.2
 
4109
 
4110
  imurmurhash@0.1.4: {}
4111
 
4112
+ indent-string@4.0.0: {}
4113
+
4114
  inflight@1.0.6:
4115
  dependencies:
4116
  once: 1.4.0
 
4333
  dependencies:
4334
  yallist: 3.1.1
4335
 
4336
+ lz-string@1.5.0: {}
4337
+
4338
  magic-string@0.30.21:
4339
  dependencies:
4340
  '@jridgewell/sourcemap-codec': 1.5.5
 
4350
 
4351
  midi-file@1.2.4: {}
4352
 
4353
+ min-indent@1.0.1: {}
4354
+
4355
  minimatch@10.2.6:
4356
  dependencies:
4357
  brace-expansion: 5.0.9
 
4568
 
4569
  prelude-ls@1.2.1: {}
4570
 
4571
+ pretty-format@27.5.1:
4572
+ dependencies:
4573
+ ansi-regex: 5.0.1
4574
+ ansi-styles: 5.2.0
4575
+ react-is: 17.0.2
4576
+
4577
  prop-types@15.8.1:
4578
  dependencies:
4579
  loose-envify: 1.4.0
 
4592
 
4593
  react-is@16.13.1: {}
4594
 
4595
+ react-is@17.0.2: {}
4596
+
4597
  react-refresh@0.17.0: {}
4598
 
4599
  react@18.3.1:
 
4608
  dependencies:
4609
  picomatch: 2.3.2
4610
 
4611
+ redent@3.0.0:
4612
+ dependencies:
4613
+ indent-string: 4.0.0
4614
+ strip-indent: 3.0.0
4615
+
4616
  reflect.getprototypeof@1.0.10:
4617
  dependencies:
4618
  call-bind: 1.0.9
 
4870
 
4871
  strip-bom@3.0.0: {}
4872
 
4873
+ strip-indent@3.0.0:
4874
+ dependencies:
4875
+ min-indent: 1.0.1
4876
+
4877
  strip-json-comments@3.1.1: {}
4878
 
4879
  styled-jsx@5.1.1(@babel/core@7.29.7)(react@18.3.1):